From 91c8577dfff713ef9e324e48d6aaec2e089e247a Mon Sep 17 00:00:00 2001 From: Bart Veneman <1536852+bartveneman@users.noreply.github.com> Date: Sun, 10 Nov 2024 23:17:17 +0100 Subject: [PATCH] rewrite the whole thing --- src/TreeNode.js | 60 +++++++++++ src/index.js | 245 ++++++++++++++++++-------------------------- test/import.spec.js | 30 ++++-- test/layer.spec.js | 70 ++++++++++--- vite.config.js | 9 +- 5 files changed, 243 insertions(+), 171 deletions(-) create mode 100644 src/TreeNode.js diff --git a/src/TreeNode.js b/src/TreeNode.js new file mode 100644 index 0000000..1fae15c --- /dev/null +++ b/src/TreeNode.js @@ -0,0 +1,60 @@ +/** @template T*/ +export class TreeNode { + /** @param {string} name */ + constructor(name) { + /** @type {string} */ + this.name = name + /** @type {Map>} */ + this.children = new Map() + /** @type {T[]} */ + this.locations = [] // Store metadata for each location added + } + + /** + * + * @param {string[]} path + * @param {string} name + * @param {T} location + */ + add_child(path, name, location) { + let current = this + + // Traverse path to find the correct location + path.forEach((segment) => { + // @ts-expect-error Apparently, TypeScript doesn't know that current is a TreeNode + current = current.children.get(segment) + }) + + // If the item already exists, add the location to its metadata + if (current.children.has(name)) { + // @ts-expect-error Apparently, TypeScript doesn't know that current is a TreeNode + current.children.get(name).locations.push(location) + } else { + // Otherwise, create the item and add the location + const new_node = new TreeNode(name) + new_node.locations.push(location) + current.children.set(name, new_node) + } + } + + /** + * @typedef PlainObject + * @property {string} name + * @property {T[]} locations + * @property {PlainObject[]} children + */ + + /** + * Convert the tree to a plain object for easy testing + * @returns {PlainObject} + */ + to_plain_object() { + return { + name: this.name, + locations: this.locations, + children: Array + .from(this.children.values()) + .map((child) => child.to_plain_object()), + } + } +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index 2ad2544..991427a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,204 +1,153 @@ import * as csstree from 'css-tree' +import { TreeNode } from './TreeNode.js' /** - * @typedef {Object} LayerTree - * @property {string} name - * @property {LayerTree[]} children + * @typedef Location + * @property {number} line + * @property {number} column + * @property {number} start + * @property {number} end */ -class List { - /** @type {string} */ - name - /** @type {List[]} */ - children - - /** - * @param {string | undefined} name - */ - constructor(name = undefined) { - this.name = name || 'root' - this.children = [] - } - - /** @param {string} name */ - has(name) { - for (let child of this.children) { - if (child.name === name) { - return true - } - } - return false - } - - /** - * - * @param {string} name - * @returns - */ - push(name) { - if (this.has(name) && name !== '') { - return this.children.find((child) => child.name === name) - } - - let new_item = new List(name) - this.children.push(new_item) - return new_item - } - - /** - * @returns {LayerTree} - */ - serialize() { - return { - name: this.name, - children: this.children.map((child) => child.serialize()), - } - } -} - /** - * Get the parent Atrule for `childNode` - * @param {import('css-tree').CssNode} ast The AST to search in - * @param {import('css-tree').Atrule} childNode The Atrule we want to get the potential parent Atrule for + * @param {import('css-tree').CssNode} node + * @returns {Location | undefined} */ -function get_parent_rule(ast, childNode) { - let parent - csstree.walk(ast, { - visit: 'Atrule', - enter: function (/** @type {import('css-tree').Atrule} */node) { - if (node === childNode && this.atrule) { - parent = this.atrule - return this.break - } - }, - }) - return parent +function get_location(node) { + let loc = node.loc + if (!loc) return + return { + line: loc.start.line, + column: loc.start.column, + start: loc.start.offset, + end: loc.end.offset, + } } -/** - * @param {import('css-tree').AtrulePrelude | import('css-tree').Raw | null} prelude - * @returns string - */ -function get_layer_name(prelude) { - return prelude === null ? '' : csstree.generate(prelude) +/** @param {import('css-tree').Atrule} node */ +function is_layer(node) { + return node.name.toLowerCase() === 'layer' } /** - * * @param {import('css-tree').CssNode} ast - * @param {import('css-tree').Atrule} atrule - * @returns {string[]} */ -function resolve_parent_tree(ast, atrule) { - let stack = [] - - // @ts-expect-error Let me just do a while loop plz - while ((atrule = get_parent_rule(ast, atrule))) { - if (atrule.name === 'layer') { - stack.unshift(get_layer_name(atrule.prelude)) - } +export function get_tree_from_ast(ast) { + /** @type {string[]} */ + let current_stack = [] + let root = new TreeNode('root') + let anonymous_counter = 0 + + /** @returns {string} */ + function get_anonymous_id() { + anonymous_counter++ + return `__anonymous-${anonymous_counter}__` } - return stack -} - -/** - * @param {import('css-tree').CssNode} ast - * @returns {string[][]} - */ -export function get_ast_tree(ast) { - /** @type {string[][]} */ - let list = [] + /** + * @param {import('css-tree').AtrulePrelude} prelude + * @returns {string[]} + */ + function get_layer_names(prelude) { + return csstree + // @todo: fewer loops plz + .generate(prelude) + .split('.') + .map((s) => s.trim()) + } csstree.walk(ast, { visit: 'Atrule', - enter: function (/** @type {import('css-tree').Atrule} */ node) { - if (node.name === 'layer') { - let layer_name = get_layer_name(node.prelude) + enter(node) { + if (is_layer(node)) { + let location = get_location(node) + + if (node.prelude === null) { + let layer_name = get_anonymous_id() + root.add_child(current_stack, layer_name, location) + current_stack.push(layer_name) + return + } - // @layer first, second; - if (node.block === null) { - for (let name of layer_name.split(',')) { - list.push([...resolve_parent_tree(ast, node), name.trim()]) + if (node.prelude.type === 'AtrulePrelude') { + if (node.block === null) { + // @ts-expect-error CSSTree types are not updated yet in @types/css-tree + let prelude = csstree.findAll(node.prelude, n => n.type === 'Layer').map(n => n.name) + for (let name of prelude) { + root.add_child(current_stack, name, location) + } + } else { + for (let layer_name of get_layer_names(node.prelude)) { + root.add_child(current_stack, layer_name, location) + current_stack.push(layer_name) + } } - - return this.skip } + } else if (node.name.toLowerCase() === 'import' && node.prelude !== null && node.prelude.type === 'AtrulePrelude') { + let location = get_location(node) + let prelude = node.prelude - // @layer first { /* content */ } - list.push([...resolve_parent_tree(ast, node), layer_name]) - return this.skip - } else if (node.name === 'import' && node.prelude !== null) { // @import url("foo.css") layer(test); + // OR + // @import url("foo.css") layer(test.nested); // @ts-expect-error CSSTree types are not updated to v3 yet - let layer = csstree.find(node.prelude, (pr_node) => pr_node.type === 'Layer') + let layer = csstree.find(prelude, n => n.type === 'Layer') if (layer) { // @ts-expect-error CSSTree types are not updated to v3 yet - list.push([layer.name]) + for (let layer_name of get_layer_names(layer)) { + root.add_child(current_stack, layer_name, location) + current_stack.push(layer_name) + } return this.skip } // @import url("foo.css") layer(); - let layer_fn = csstree.find( - node.prelude, - (pr_node) => - pr_node.type === 'Function' && pr_node.name.toLowerCase() === 'layer' - ) + let layer_fn = csstree.find(prelude, n => n.type === 'Function' && n.name.toLowerCase() === 'layer') if (layer_fn) { - list.push(['']) + root.add_child([], get_anonymous_id(), location) return this.skip } // @import url("foo.css") layer; - let layer_keyword = csstree.find( - node.prelude, - (pre_node) => - pre_node.type === 'Identifier' && pre_node.name.toLowerCase() === 'layer' - ) + let layer_keyword = csstree.find(prelude, n => n.type === 'Identifier' && n.name.toLowerCase() === 'layer') if (layer_keyword) { - list.push(['']) + root.add_child([], get_anonymous_id(), location) return this.skip } } - return this.skip - } + }, + leave(node) { + if (is_layer(node)) { + if (node.prelude !== null && node.prelude.type === 'AtrulePrelude') { + let layer_names = get_layer_names(node.prelude) + for (let i = 0; i < layer_names.length; i++) { + current_stack.pop() + } + } else { + // pop the anonymous layer + current_stack.pop() + } + } else if (node.name.toLowerCase() === 'import') { + // clear the stack, imports can not be nested + current_stack.length = 0 + } + }, }) - return list + return root.to_plain_object().children } /** * @param {string} css - * @returns {LayerTree[]} */ export function get_tree(css) { let ast = csstree.parse(css, { positions: true, parseAtrulePrelude: true, - parseRulePrelude: false, parseValue: false, + parseRulePrelude: false, parseCustomProperty: false, }) - let list_of_layers = get_ast_tree(ast).map((layer) => layer.join('.')) - - let known = new List() - - for (let name of list_of_layers) { - if (name.includes('.')) { - let parts = name.split('.') - // @ts-expect-error Let me just do a while loop plz - let last_item = known.push(parts.shift()) - - while (parts.length > 0 && last_item) { - // @ts-expect-error Let me just do a while loop plz - last_item = last_item.push(parts.shift()) - } - - continue - } - - known.push(name) - } - return known.children.map((child) => child.serialize()) + return get_tree_from_ast(ast) } \ No newline at end of file diff --git a/test/import.spec.js b/test/import.spec.js index 1707a16..453cfee 100644 --- a/test/import.spec.js +++ b/test/import.spec.js @@ -4,19 +4,19 @@ import { get_tree } from '../src/index.js' test('@import url() layer', () => { let actual = get_tree('@import url("foo.css") layer;') - let expected = [ - { - name: '', - children: [] - } - ] + let expected = [{ + name: '__anonymous-1__', + locations: [{ line: 1, column: 1, start: 0, end: 29 }], + children: [] + }] assert.equal(actual, expected) }) test('@import url() LAYER', () => { let actual = get_tree('@import url("foo.css") LAYER;') let expected = [{ - "name": "", + "name": "__anonymous-1__", + locations: [{ line: 1, column: 1, start: 0, end: 29 }], "children": [] }] assert.equal(actual, expected) @@ -25,7 +25,8 @@ test('@import url() LAYER', () => { test('@import url() layer()', () => { let actual = get_tree('@import url("foo.css") layer();') let expected = [{ - name: '', + name: '__anonymous-1__', + locations: [{ line: 1, column: 1, start: 0, end: 31 }], children: [] }] assert.equal(actual, expected) @@ -34,7 +35,8 @@ test('@import url() layer()', () => { test('@import url() LAYER()', () => { let actual = get_tree('@import url("foo.css") LAYER();') let expected = [{ - name: '', + name: '__anonymous-1__', + locations: [{ line: 1, column: 1, start: 0, end: 31 }], children: [] }] assert.equal(actual, expected) @@ -44,6 +46,7 @@ test('@import url() layer(named)', () => { let actual = get_tree('@import url("foo.css") layer(named);') let expected = [{ name: 'named', + locations: [{ line: 1, column: 1, start: 0, end: 36 }], children: [] }] assert.equal(actual, expected) @@ -53,6 +56,7 @@ test('@import url() LAYER(named)', () => { let actual = get_tree('@import url("foo.css") LAYER(named);') let expected = [{ name: 'named', + locations: [{ line: 1, column: 1, start: 0, end: 36 }], children: [] }] assert.equal(actual, expected) @@ -62,8 +66,10 @@ test('@import url() layer(named.nested)', () => { let actual = get_tree('@import url("foo.css") layer(named.nested);') let expected = [{ name: 'named', + locations: [{ line: 1, column: 1, start: 0, end: 43 }], children: [{ name: 'nested', + locations: [{ line: 1, column: 1, start: 0, end: 43 }], children: [] }] }] @@ -74,20 +80,24 @@ test('@import url() layer(named.nested )', () => { let actual = get_tree('@import url("foo.css") layer(named.nested );') let expected = [{ name: 'named', + locations: [{ line: 1, column: 1, start: 0, end: 48 }], children: [{ name: 'nested', + locations: [{ line: 1, column: 1, start: 0, end: 48 }], children: [] }] }] assert.equal(actual, expected) }) -test('@import url() layer(/* test */named.nested)', () => { +test('@import url() layer(/* test */named.nested )', () => { let actual = get_tree('@import url("foo.css") layer(/* test */named.nested );') let expected = [{ name: 'named', + locations: [{ line: 1, column: 1, start: 0, end: 58 }], children: [{ name: 'nested', + locations: [{ line: 1, column: 1, start: 0, end: 58 }], children: [] }] }] diff --git a/test/layer.spec.js b/test/layer.spec.js index c117988..3d72c90 100644 --- a/test/layer.spec.js +++ b/test/layer.spec.js @@ -6,8 +6,9 @@ test('single anonymous layer without body', () => { let actual = get_tree('@layer;') let expected = [ { - name: '', + name: '__anonymous-1__', children: [], + locations: [{ line: 1, column: 1, start: 0, end: 7 }] }, ] assert.equal(actual, expected) @@ -17,8 +18,9 @@ test('single anonymous layer with body', () => { let actual = get_tree('@layer {}') let expected = [ { - name: '', + name: '__anonymous-1__', children: [], + locations: [{ line: 1, column: 1, start: 0, end: 9 }] }, ] assert.equal(actual, expected) @@ -30,6 +32,7 @@ test('single named layer without body', () => { { name: 'first', children: [], + locations: [{ line: 1, column: 1, start: 0, end: 13 }] }, ] assert.equal(actual, expected) @@ -41,21 +44,42 @@ test('single named layer with body', () => { { name: 'first', children: [], + locations: [{ line: 1, column: 1, start: 0, end: 15 }] }, ] assert.equal(actual, expected) }) test('multiple named layers in one line', () => { - let actual = get_tree('@layer first, second;') + let actual = get_tree(`@layer first, second;`) let expected = [ { name: 'first', children: [], + locations: [{ line: 1, column: 1, start: 0, end: 21 }] }, { name: 'second', children: [], + locations: [{ line: 1, column: 1, start: 0, end: 21 }] + }, + ] + assert.equal(actual, expected) +}) + +test('repeated use of the same layer name', () => { + let actual = get_tree(` + @layer first {} + @layer first {} + `) + let expected = [ + { + name: 'first', + children: [], + locations: [ + { line: 2, column: 3, start: 3, end: 18 }, + { line: 3, column: 3, start: 21, end: 36 } + ] }, ] assert.equal(actual, expected) @@ -64,16 +88,33 @@ test('multiple named layers in one line', () => { test('nested layers', () => { let actual = get_tree(` @layer first { - @layer second {} + @layer second { + @layer third {} + @media all {} + @layer fourth {} + } } `) let expected = [ { name: 'first', + locations: [{ line: 2, column: 3, start: 3, end: 104 }], children: [ { name: 'second', - children: [], + locations: [{ line: 3, column: 4, start: 21, end: 100 }], + children: [ + { + name: 'third', + locations: [{ line: 4, column: 5, start: 41, end: 56 }], + children: [], + }, + { + name: 'fourth', + locations: [{ line: 6, column: 5, start: 79, end: 95 }], + children: [], + }, + ], }, ], }, @@ -81,7 +122,7 @@ test('nested layers', () => { assert.equal(actual, expected) }) -test.skip('nested layers with anonymous layers', () => { +test('nested layers with anonymous layers', () => { let actual = get_tree(` @layer { @layer {} @@ -89,11 +130,13 @@ test.skip('nested layers with anonymous layers', () => { `) let expected = [ { - name: '', + name: '__anonymous-1__', + locations: [{ line: 2, column: 3, start: 3, end: 28 }], children: [ { - name: '', + name: '__anonymous-2__', children: [], + locations: [{ line: 3, column: 4, start: 15, end: 24 }], }, ], }, @@ -101,7 +144,7 @@ test.skip('nested layers with anonymous layers', () => { assert.equal(actual, expected) }) -test.skip('nested layers with anonymous layers and duplicate names', () => { +test('nested layers with anonymous layers and duplicate names', () => { let actual = get_tree(` @layer { @layer first {} @@ -111,16 +154,19 @@ test.skip('nested layers with anonymous layers and duplicate names', () => { `) let expected = [ { - name: '', + name: '__anonymous-1__', + locations: [{ line: 2, column: 3, start: 3, end: 34 }], children: [ { name: 'first', children: [], - } - ], + locations: [{ line: 3, column: 4, start: 15, end: 30 }], + }, + ] }, { name: 'first', + locations: [{ line: 6, column: 3, start: 38, end: 53 }], children: [], }, ] diff --git a/vite.config.js b/vite.config.js index 56080a4..b59ce79 100644 --- a/vite.config.js +++ b/vite.config.js @@ -13,7 +13,14 @@ export default defineConfig({ rollupOptions: { // make sure to externalize deps that shouldn't be bundled // into your library - external: [], + external: ['css-tree'], + output: { + // Provide global variables to use in the UMD build + // for externalized deps + globals: { + 'css-tree': 'csstree', + }, + }, }, }, plugins: [