diff --git a/nativescript-core/css/css-tree-parser.ts b/nativescript-core/css/css-tree-parser.ts new file mode 100644 index 0000000000..b7aa1413f3 --- /dev/null +++ b/nativescript-core/css/css-tree-parser.ts @@ -0,0 +1,140 @@ +import { + parse +} from "css-tree"; + +function mapSelectors(selector: string): string[] { + if (!selector) { + return []; + } + + return selector.split(/\s*(?![^(]*\)),\s*/).map(s => s.replace(/\u200C/g, ",")); +} + +function mapPosition(node, css) { + let res: any = { + start: { + line: node.loc.start.line, + column: node.loc.start.column + }, + end: { + line: node.loc.end.line, + column: node.loc.end.column + }, + content: css + }; + + if (node.loc.source && node.loc.source !== "") { + res.source = node.loc.source; + } + + return res; +} + +function transformAst(node, css, type = null) { + if (!node) { + return; + } + + if (node.type === "StyleSheet") { + return { + type: "stylesheet", + stylesheet: { + rules: node.children.map(child => transformAst(child, css)).toArray(), + parsingErrors: [] + } + }; + } + + if (node.type === "Atrule") { + let atrule: any = { + type: node.name, + }; + + if (node.name === "supports" || node.name === "media") { + atrule[node.name] = node.prelude.value; + atrule.rules = transformAst(node.block, css); + } else if (node.name === "page") { + atrule.selectors = node.prelude ? mapSelectors(node.prelude.value) : []; + atrule.declarations = transformAst(node.block, css); + } else if (node.name === "document") { + atrule.document = node.prelude ? node.prelude.value : ""; + atrule.vendor = ""; + atrule.rules = transformAst(node.block, css); + } else if (node.name === "font-face") { + atrule.declarations = transformAst(node.block, css); + } else if (node.name === "import" || node.name === "charset" || node.name === "namespace") { + atrule[node.name] = node.prelude ? node.prelude.value : ""; + } else if (node.name === "keyframes") { + atrule.name = node.prelude ? node.prelude.value : ""; + atrule.keyframes = transformAst(node.block, css, "keyframe"); + atrule.vendor = undefined; + } else { + atrule.rules = transformAst(node.block, css); + } + + atrule.position = mapPosition(node, css); + + return atrule; + } + + if (node.type === "Block") { + return node.children.map(child => transformAst(child, css, type)).toArray(); + } + + if (node.type === "Rule") { + let value = node.prelude.value; + + let res: any = { + type: type != null ? type : "rule", + declarations: transformAst(node.block, css), + position: mapPosition(node, css) + }; + + if (type === "keyframe") { + res.values = mapSelectors(value); + } else { + res.selectors = mapSelectors(value); + } + + return res; + } + + if (node.type === "Comment") { + return { + type: "comment", + comment: node.value, + position: mapPosition(node, css) + }; + } + + if (node.type === "Declaration") { + return { + type: "declaration", + property: node.property, + value: node.value.value ? node.value.value.trim() : "", + position: mapPosition(node, css) + }; + } + + throw Error(`Unknown node type ${node.type}`); +} + +export function cssTreeParse(css, source): any { + let errors = []; + let ast = parse(css, { + parseValue: false, + parseAtrulePrelude: false, + parseRulePrelude: false, + positions: true, + filename: source, + onParseError: error => { + errors.push(`${source}:${error.line}:${error.column}: ${error.formattedMessage}`); + } + }); + + if (errors.length > 0) { + throw new Error(errors[0]); + } + + return transformAst(ast, css); +} \ No newline at end of file diff --git a/nativescript-core/package.json b/nativescript-core/package.json index a0441e28b7..5ff92805f8 100644 --- a/nativescript-core/package.json +++ b/nativescript-core/package.json @@ -20,6 +20,7 @@ "dependencies": { "nativescript-hook": "0.2.5", "reduce-css-calc": "^2.1.6", + "css-tree": "^1.0.0-alpha.37", "semver": "6.3.0", "tns-core-modules-widgets": "next", "tslib": "1.10.0" diff --git a/nativescript-core/ui/styling/style-scope.ts b/nativescript-core/ui/styling/style-scope.ts index ed585227d1..ea4087ccdd 100644 --- a/nativescript-core/ui/styling/style-scope.ts +++ b/nativescript-core/ui/styling/style-scope.ts @@ -12,6 +12,9 @@ import { CSS3Parser, CSSNativeScript } from "../../css/parser"; +import { + cssTreeParse +} from "../../css/css-tree-parser"; import { RuleSet, @@ -50,11 +53,15 @@ function ensureCssAnimationParserModule() { } } -let parser: "rework" | "nativescript" = "rework"; +let parser: "rework" | "nativescript" | "css-tree" = "rework"; try { const appConfig = require("~/package.json"); - if (appConfig && appConfig.cssParser === "nativescript") { - parser = "nativescript"; + if (appConfig) { + if (appConfig.cssParser === "css-tree") { + parser = "css-tree"; + } else if (appConfig.cssParser === "nativescript") { + parser = "nativescript"; + } } } catch (e) { // @@ -220,6 +227,10 @@ class CSSSource { private parseCSSAst() { if (this._source) { switch (parser) { + case "css-tree": + this._ast = cssTreeParse(this._source, this._file); + + return; case "nativescript": const cssparser = new CSS3Parser(this._source); const stylesheet = cssparser.parseAStylesheet(); diff --git a/package.json b/package.json index e68f0c5068..72ecee0d91 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@types/node": "~10.12.18", "chai": "^4.1.2", "css": "^2.2.1", - "css-tree": "^1.0.0-alpha24", + "css-tree": "^1.0.0-alpha.37", "gonzales": "^1.0.7", "madge": "^2.2.0", "markdown-snippet-injector": "0.2.2", diff --git a/tests/app/ui/styling/style-tests.ts b/tests/app/ui/styling/style-tests.ts index 37835cbb4f..ee4b834432 100644 --- a/tests/app/ui/styling/style-tests.ts +++ b/tests/app/ui/styling/style-tests.ts @@ -1584,7 +1584,7 @@ export function test_nested_css_calc() { stack.className = "wide"; TKUnit.assertEqual(stack.width as any, 125, "Stack - width === 125"); - (stack as any).style = `width: calc(100% * calc(1 / 2)`; + (stack as any).style = `width: calc(100% * calc(1 / 2))`; TKUnit.assertDeepEqual(stack.width, { unit: "%", value: 0.5 }, "Stack - width === 50%"); } diff --git a/unit-tests/css-tree-parser/css-tree-parser.ts b/unit-tests/css-tree-parser/css-tree-parser.ts new file mode 100644 index 0000000000..c72e7f0984 --- /dev/null +++ b/unit-tests/css-tree-parser/css-tree-parser.ts @@ -0,0 +1,85 @@ +import { cssTreeParse } from "@nativescript/core/css/css-tree-parser"; +import { parse as reworkCssParse } from "@nativescript/core/css"; +import { assert } from "chai"; + +describe("css-tree parser compatible with rework ", () => { + it("basic selector", () => { + const testCase = ".test { color: red; }"; + const reworkAST = reworkCssParse(testCase, { source: "file.css" }); + const cssTreeAST = cssTreeParse(testCase, "file.css"); + + assert.deepEqual(cssTreeAST, reworkAST); + }); + + it("@keyframes", () => { + const testCase = ".test { animation-name: test; } @keyframes test { from { background-color: red; } to { background-color: blue; } } .test { color: red; }"; + const reworkAST = reworkCssParse(testCase, { source: "file.css" }); + const cssTreeAST = cssTreeParse(testCase, "file.css"); + + assert.deepEqual(cssTreeAST, reworkAST); + }); + + it("@media", () => { + const testCase = "@media screen and (max-width: 600px) { body { background-color: olive; } } .test { color: red; }"; + const reworkAST = reworkCssParse(testCase, { source: "file.css" }); + const cssTreeAST = cssTreeParse(testCase, "file.css"); + + assert.deepEqual(cssTreeAST, reworkAST); + }); + + it("@supports", () => { + const testCase = "@supports not (display: grid) { div { float: right; } } .test { color: red; }"; + const reworkAST = reworkCssParse(testCase, { source: "file.css" }); + const cssTreeAST = cssTreeParse(testCase, "file.css"); + + assert.deepEqual(cssTreeAST, reworkAST); + }); + + it("@page", () => { + const testCase = "@page :first { margin: 2cm; } .test { color: red; }"; + const reworkAST = reworkCssParse(testCase, { source: "file.css" }); + const cssTreeAST = cssTreeParse(testCase, "file.css"); + + assert.deepEqual(cssTreeAST, reworkAST); + }); + + it("@document", () => { + const testCase = "@document url(\"https://www.example.com/\") { h1 { color: green; } } .test { color: red; }"; + const reworkAST = reworkCssParse(testCase, { source: "file.css" }); + const cssTreeAST = cssTreeParse(testCase, "file.css"); + + assert.deepEqual(cssTreeAST, reworkAST); + }); + + it("@font-face", () => { + const testCase = "@font-face { font-family: \"Open Sans\"; src: url(\"/fonts/OpenSans-Regular-webfont.woff2\") format(\"woff2\"), url(\"/fonts/OpenSans-Regular-webfont.woff\") format(\"woff\"); } .test { color: red; }"; + const reworkAST = reworkCssParse(testCase, { source: "file.css" }); + const cssTreeAST = cssTreeParse(testCase, "file.css"); + + assert.deepEqual(cssTreeAST, reworkAST); + }); + + it("@import", () => { + const testCase = "@import url('landscape.css') screen and (orientation:landscape); @import url(\"fineprint.css\") print; .test { color: red; }"; + const reworkAST = reworkCssParse(testCase, { source: "file.css" }); + const cssTreeAST = cssTreeParse(testCase, "file.css"); + + assert.deepEqual(cssTreeAST, reworkAST); + }); + + it("@charset", () => { + const testCase = "@charset \"utf-8\"; .test { color: red; }"; + const reworkAST = reworkCssParse(testCase, { source: "file.css" }); + const cssTreeAST = cssTreeParse(testCase, "file.css"); + + assert.deepEqual(cssTreeAST, reworkAST); + }); + + it("@namespace", () => { + const testCase = "@namespace svg url(http://www.w3.org/2000/svg); .test { color: red; }"; + const reworkAST = reworkCssParse(testCase, { source: "file.css" }); + const cssTreeAST = cssTreeParse(testCase, "file.css"); + + assert.deepEqual(cssTreeAST, reworkAST); + }); +}); \ No newline at end of file