diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91cba14..03170ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,28 +7,21 @@ on: release: types: [created] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - run: npm install --ignore-scripts --no-audit --no-fund - - run: npm test +permissions: + id-token: write # Required for OIDC + contents: read +jobs: publish-npm: - needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - cache: 'npm' + node-version: 22 registry-url: https://registry.npmjs.org/ + - run: npm install -g npm@latest - run: npm install --ignore-scripts --no-audit --no-fund + - run: npm test - run: npm run build - - run: npm publish --public - env: - NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + - run: npm publish --access public diff --git a/README.md b/README.md index c9d1818..ee8a47a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ npm install @projectwallace/css-layer-tree ## Usage ```js -import { get_tree } from '@projectwallace/css-layer-tree' +import { layer_tree } from '@projectwallace/css-layer-tree' let css = ` @import url("test.css") layer; @@ -25,7 +25,7 @@ let css = ` @layer {} ` -let tree = get_tree(css) +let tree = layer_tree(css) ``` This example would result in this `tree`: @@ -59,6 +59,11 @@ This example would result in this `tree`: }, ], }, + { + name: '__anonymous-2__', + locations: [{ line: 10, column: 3, start: 176, end: 185 }], + children: [], + }, ] ``` diff --git a/index.d.ts b/index.d.ts index 961ebdf..aa31c9c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -9,9 +9,10 @@ export type Location = { export type TreeNode = { name: string; + is_anonymous: boolean; children: TreeNode[]; locations: Location[]; } -export function get_tree_from_ast(ast: CssNode): TreeNode['children']; -export function get_tree(css: string): TreeNode['children']; +export function layer_tree_from_ast(ast: CssNode): TreeNode[]; +export function layer_tree(css: string): TreeNode[]; diff --git a/package-lock.json b/package-lock.json index 5762170..095a6c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@projectwallace/css-layer-tree", - "version": "0.0.1", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@projectwallace/css-layer-tree", - "version": "0.0.1", + "version": "2.0.2", "license": "MIT", "dependencies": { "css-tree": "^3.0.0" @@ -19,6 +19,9 @@ "typescript": "5.4.2", "uvu": "^0.5.6", "vite": "^5.4.10" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@actions/core": { @@ -549,6 +552,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.0.tgz", "integrity": "sha512-1LFfa/qnMQvEOAdzlQymH0ulepxbxnCYAKJZfMci/5XJyIHWgEYnDmgnKakbTh7CH2tFQ5O60oYDvns4i9RAIg==", "dev": true, + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -1985,6 +1989,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index 6de5fee..539a5f2 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,27 @@ { "name": "@projectwallace/css-layer-tree", - "version": "0.0.1", - "description": "Discover the composition of your CSS @layers", + "version": "2.0.2", + "description": "Discover the composition of your CSS @layers in a tree-based format.", "repository": { "type": "git", - "url": "git+https://github.com/projectwallace/css-layers.git" + "url": "git+https://github.com/projectwallace/css-layer-tree.git" + }, + "author": { + "name": "Bart Veneman" }, "homepage": "https://github.com/projectwallace/css-layer-tree", "issues": "https://github.com/projectwallace/css-layer-tree/issues", "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, "type": "module", - "source": "src/index.js", + "main": "./dist/css-layer-tree.js", + "types": "./index.d.ts", "exports": { "types": "./index.d.ts", - "require": "./dist/css-layer-tree.umd.cjs", "default": "./dist/css-layer-tree.js" }, - "types": "./index.d.ts", - "main": "./dist/css-layer-tree.umd.cjs", - "module": "./dist/css-layer-tree.js", - "unpkg": "./dist/css-layer-tree.umd.cjs", "scripts": { "build": "vite build", "test": "c8 --reporter=lcov uvu", diff --git a/src/TreeNode.js b/src/TreeNode.js index 1fae15c..c9abe54 100644 --- a/src/TreeNode.js +++ b/src/TreeNode.js @@ -4,14 +4,15 @@ export class TreeNode { constructor(name) { /** @type {string} */ this.name = name + /** @type {boolean} */ + this.is_anonymous = false /** @type {Map>} */ this.children = new Map() /** @type {T[]} */ - this.locations = [] // Store metadata for each location added + this.locations = [] } /** - * * @param {string[]} path * @param {string} name * @param {T} location @@ -27,12 +28,17 @@ export class TreeNode { // 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) + if (location !== undefined) { + // @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) + if (location !== undefined) { + new_node.locations.push(location) + } + new_node.is_anonymous = name.startsWith('__anonymous') current.children.set(name, new_node) } } @@ -40,6 +46,7 @@ export class TreeNode { /** * @typedef PlainObject * @property {string} name + * @property {boolean} is_anonymous * @property {T[]} locations * @property {PlainObject[]} children */ @@ -51,10 +58,9 @@ export class TreeNode { to_plain_object() { return { name: this.name, + is_anonymous: this.is_anonymous, locations: this.locations, - children: Array - .from(this.children.values()) - .map((child) => child.to_plain_object()), + children: Array.from(this.children.values(), (child) => child.to_plain_object()), } } -} \ No newline at end of file +} diff --git a/src/index.js b/src/index.js index 991427a..30bd728 100644 --- a/src/index.js +++ b/src/index.js @@ -29,10 +29,22 @@ function is_layer(node) { return node.name.toLowerCase() === 'layer' } +/** + * @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()) +} + /** * @param {import('css-tree').CssNode} ast */ -export function get_tree_from_ast(ast) { +export function layer_tree_from_ast(ast) { /** @type {string[]} */ let current_stack = [] let root = new TreeNode('root') @@ -44,18 +56,6 @@ export function get_tree_from_ast(ast) { return `__anonymous-${anonymous_counter}__` } - /** - * @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(node) { @@ -66,15 +66,23 @@ export function get_tree_from_ast(ast) { let layer_name = get_anonymous_id() root.add_child(current_stack, layer_name, location) current_stack.push(layer_name) - return - } - - if (node.prelude.type === 'AtrulePrelude') { + } else 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) + // Split the layer name by dots to handle nested layers + let parts = name.split('.').map((/** @type {string} */ s) => s.trim()) + + // Ensure all parent layers exist and add them to the tree + for (let i = 0; i < parts.length; i++) { + let path = parts.slice(0, i) + let layerName = parts[i] + // Only add location to the final layer in dotted notation + // Create a new copy to avoid sharing references + let loc = i === parts.length - 1 ? {...location} : undefined + root.add_child(path, layerName, loc) + } } } else { for (let layer_name of get_layer_names(node.prelude)) { @@ -101,13 +109,6 @@ export function get_tree_from_ast(ast) { return this.skip } - // @import url("foo.css") layer(); - let layer_fn = csstree.find(prelude, n => n.type === 'Function' && n.name.toLowerCase() === 'layer') - if (layer_fn) { - root.add_child([], get_anonymous_id(), location) - return this.skip - } - // @import url("foo.css") layer; let layer_keyword = csstree.find(prelude, n => n.type === 'Identifier' && n.name.toLowerCase() === 'layer') if (layer_keyword) { @@ -140,7 +141,7 @@ export function get_tree_from_ast(ast) { /** * @param {string} css */ -export function get_tree(css) { +export function layer_tree(css) { let ast = csstree.parse(css, { positions: true, parseAtrulePrelude: true, @@ -149,5 +150,5 @@ export function get_tree(css) { parseCustomProperty: false, }) - return get_tree_from_ast(ast) + return layer_tree_from_ast(ast) } \ No newline at end of file diff --git a/test/global.spec.js b/test/global.spec.js index 640f34a..0f08623 100644 --- a/test/global.spec.js +++ b/test/global.spec.js @@ -1,55 +1,147 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' -import { get_tree } from '../src/index.js' +import { layer_tree } from '../src/index.js' test('handles empty input', () => { - assert.equal(get_tree(''), []) + assert.equal(layer_tree(''), []) }) test('handles CSS without layers', () => { - assert.equal(get_tree('@media all { body { color: red; } }'), []) + assert.equal(layer_tree('@media all { body { color: red; } }'), []) }) test('mixed imports and layers', () => { - let actual = get_tree(` + let actual = layer_tree(` @import url("test.css") layer; @import url("test.css") LAYER(test); @layer anotherTest { @layer moreTest { @layer deepTest {} } - }; + } /* anonymous @layer */ @layer {} `) let expected = [ { name: '__anonymous-1__', + is_anonymous: true, locations: [{ line: 2, column: 3, start: 3, end: 33 }], - children: [] + children: [], }, { name: 'test', + is_anonymous: false, locations: [{ line: 3, column: 3, start: 36, end: 72 }], - children: [] + children: [], }, { name: 'anotherTest', + is_anonymous: false, locations: [{ line: 4, column: 3, start: 75, end: 148 }], children: [ { name: 'moreTest', + is_anonymous: false, locations: [{ line: 5, column: 4, start: 99, end: 144 }], children: [ { name: 'deepTest', + is_anonymous: false, locations: [{ line: 6, column: 5, start: 121, end: 139 }], - children: [] - } - ] - } - ] - } + children: [], + }, + ], + }, + ], + }, + { + name: '__anonymous-2__', + is_anonymous: true, + locations: [{ line: 10, column: 3, start: 176, end: 185 }], + children: [], + }, + ] + assert.equal(actual, expected) +}) + +test('the fokus.dev boilerplate', () => { + let actual = layer_tree(` + @layer core, third-party, components, utility; + @layer core.reset, core.tokens, core.base; + @layer third-party.imports, third-party.overrides; + @layer components.base, components.variations; + `) + let expected = [ + { + name: 'core', + is_anonymous: false, + locations: [{ line: 2, column: 3, start: 3, end: 49 }], + children: [ + { + name: 'reset', + is_anonymous: false, + locations: [{ line: 3, column: 3, start: 52, end: 94 }], + children: [], + }, + { + name: 'tokens', + is_anonymous: false, + locations: [{ line: 3, column: 3, start: 52, end: 94 }], + children: [], + }, + { + name: 'base', + is_anonymous: false, + locations: [{ line: 3, column: 3, start: 52, end: 94 }], + children: [], + }, + ], + }, + { + name: 'third-party', + is_anonymous: false, + locations: [{ line: 2, column: 3, start: 3, end: 49 }], + children: [ + { + name: 'imports', + is_anonymous: false, + locations: [{ line: 4, column: 3, start: 97, end: 147 }], + children: [], + }, + { + name: 'overrides', + is_anonymous: false, + locations: [{ line: 4, column: 3, start: 97, end: 147 }], + children: [], + }, + ], + }, + { + name: 'components', + is_anonymous: false, + locations: [{ line: 2, column: 3, start: 3, end: 49 }], + children: [ + { + name: 'base', + is_anonymous: false, + locations: [{ line: 5, column: 3, start: 150, end: 196 }], + children: [], + }, + { + name: 'variations', + is_anonymous: false, + locations: [{ line: 5, column: 3, start: 150, end: 196 }], + children: [], + }, + ], + }, + { + name: 'utility', + is_anonymous: false, + locations: [{ line: 2, column: 3, start: 3, end: 49 }], + children: [], + }, ] assert.equal(actual, expected) }) diff --git a/test/import.spec.js b/test/import.spec.js index 453cfee..5e22b83 100644 --- a/test/import.spec.js +++ b/test/import.spec.js @@ -1,11 +1,12 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' -import { get_tree } from '../src/index.js' +import { layer_tree } from '../src/index.js' test('@import url() layer', () => { - let actual = get_tree('@import url("foo.css") layer;') + let actual = layer_tree('@import url("foo.css") layer;') let expected = [{ name: '__anonymous-1__', + is_anonymous: true, locations: [{ line: 1, column: 1, start: 0, end: 29 }], children: [] }] @@ -13,39 +14,21 @@ test('@import url() layer', () => { }) test('@import url() LAYER', () => { - let actual = get_tree('@import url("foo.css") LAYER;') + let actual = layer_tree('@import url("foo.css") LAYER;') let expected = [{ "name": "__anonymous-1__", + is_anonymous: true, 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: '__anonymous-1__', - locations: [{ line: 1, column: 1, start: 0, end: 31 }], - children: [] - }] - assert.equal(actual, expected) -}) - -test('@import url() LAYER()', () => { - let actual = get_tree('@import url("foo.css") LAYER();') - let expected = [{ - name: '__anonymous-1__', - locations: [{ line: 1, column: 1, start: 0, end: 31 }], - children: [] - }] - assert.equal(actual, expected) -}) - test('@import url() layer(named)', () => { - let actual = get_tree('@import url("foo.css") layer(named);') + let actual = layer_tree('@import url("foo.css") layer(named);') let expected = [{ name: 'named', + is_anonymous: false, locations: [{ line: 1, column: 1, start: 0, end: 36 }], children: [] }] @@ -53,9 +36,10 @@ test('@import url() layer(named)', () => { }) test('@import url() LAYER(named)', () => { - let actual = get_tree('@import url("foo.css") LAYER(named);') + let actual = layer_tree('@import url("foo.css") LAYER(named);') let expected = [{ name: 'named', + is_anonymous: false, locations: [{ line: 1, column: 1, start: 0, end: 36 }], children: [] }] @@ -63,12 +47,14 @@ test('@import url() LAYER(named)', () => { }) test('@import url() layer(named.nested)', () => { - let actual = get_tree('@import url("foo.css") layer(named.nested);') + let actual = layer_tree('@import url("foo.css") layer(named.nested);') let expected = [{ name: 'named', + is_anonymous: false, locations: [{ line: 1, column: 1, start: 0, end: 43 }], children: [{ name: 'nested', + is_anonymous: false, locations: [{ line: 1, column: 1, start: 0, end: 43 }], children: [] }] @@ -77,12 +63,14 @@ test('@import url() layer(named.nested)', () => { }) test('@import url() layer(named.nested )', () => { - let actual = get_tree('@import url("foo.css") layer(named.nested );') + let actual = layer_tree('@import url("foo.css") layer(named.nested );') let expected = [{ name: 'named', + is_anonymous: false, locations: [{ line: 1, column: 1, start: 0, end: 48 }], children: [{ name: 'nested', + is_anonymous: false, locations: [{ line: 1, column: 1, start: 0, end: 48 }], children: [] }] @@ -91,12 +79,14 @@ test('@import url() layer(named.nested )', () => { }) test('@import url() layer(/* test */named.nested )', () => { - let actual = get_tree('@import url("foo.css") layer(/* test */named.nested );') + let actual = layer_tree('@import url("foo.css") layer(/* test */named.nested );') let expected = [{ name: 'named', + is_anonymous: false, locations: [{ line: 1, column: 1, start: 0, end: 58 }], children: [{ name: 'nested', + is_anonymous: false, locations: [{ line: 1, column: 1, start: 0, end: 58 }], children: [] }] diff --git a/test/layer.spec.js b/test/layer.spec.js index 3d72c90..aa30478 100644 --- a/test/layer.spec.js +++ b/test/layer.spec.js @@ -1,92 +1,99 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' -import { get_tree } from '../src/index.js' +import { layer_tree } from '../src/index.js' test('single anonymous layer without body', () => { - let actual = get_tree('@layer;') + let actual = layer_tree('@layer;') let expected = [ { name: '__anonymous-1__', + is_anonymous: true, children: [], - locations: [{ line: 1, column: 1, start: 0, end: 7 }] + locations: [{ line: 1, column: 1, start: 0, end: 7 }], }, ] assert.equal(actual, expected) }) test('single anonymous layer with body', () => { - let actual = get_tree('@layer {}') + let actual = layer_tree('@layer {}') let expected = [ { name: '__anonymous-1__', + is_anonymous: true, children: [], - locations: [{ line: 1, column: 1, start: 0, end: 9 }] + locations: [{ line: 1, column: 1, start: 0, end: 9 }], }, ] assert.equal(actual, expected) }) test('single named layer without body', () => { - let actual = get_tree('@layer first;') + let actual = layer_tree('@layer first;') let expected = [ { name: 'first', + is_anonymous: false, children: [], - locations: [{ line: 1, column: 1, start: 0, end: 13 }] + locations: [{ line: 1, column: 1, start: 0, end: 13 }], }, ] assert.equal(actual, expected) }) test('single named layer with body', () => { - let actual = get_tree('@layer first {}') + let actual = layer_tree('@layer first {}') let expected = [ { name: 'first', + is_anonymous: false, children: [], - locations: [{ line: 1, column: 1, start: 0, end: 15 }] + 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 = layer_tree(`@layer first, second;`) let expected = [ { name: 'first', + is_anonymous: false, children: [], - locations: [{ line: 1, column: 1, start: 0, end: 21 }] + locations: [{ line: 1, column: 1, start: 0, end: 21 }], }, { name: 'second', + is_anonymous: false, children: [], - locations: [{ line: 1, column: 1, start: 0, end: 21 }] + 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(` + let actual = layer_tree(` @layer first {} @layer first {} `) let expected = [ { name: 'first', + is_anonymous: false, children: [], locations: [ { line: 2, column: 3, start: 3, end: 18 }, - { line: 3, column: 3, start: 21, end: 36 } - ] + { line: 3, column: 3, start: 21, end: 36 }, + ], }, ] assert.equal(actual, expected) }) test('nested layers', () => { - let actual = get_tree(` + let actual = layer_tree(` @layer first { @layer second { @layer third {} @@ -98,19 +105,23 @@ test('nested layers', () => { let expected = [ { name: 'first', + is_anonymous: false, locations: [{ line: 2, column: 3, start: 3, end: 104 }], children: [ { name: 'second', + is_anonymous: false, locations: [{ line: 3, column: 4, start: 21, end: 100 }], children: [ { name: 'third', + is_anonymous: false, locations: [{ line: 4, column: 5, start: 41, end: 56 }], children: [], }, { name: 'fourth', + is_anonymous: false, locations: [{ line: 6, column: 5, start: 79, end: 95 }], children: [], }, @@ -123,7 +134,7 @@ test('nested layers', () => { }) test('nested layers with anonymous layers', () => { - let actual = get_tree(` + let actual = layer_tree(` @layer { @layer {} } @@ -131,10 +142,12 @@ test('nested layers with anonymous layers', () => { let expected = [ { name: '__anonymous-1__', + is_anonymous: true, locations: [{ line: 2, column: 3, start: 3, end: 28 }], children: [ { name: '__anonymous-2__', + is_anonymous: true, children: [], locations: [{ line: 3, column: 4, start: 15, end: 24 }], }, @@ -144,8 +157,30 @@ test('nested layers with anonymous layers', () => { assert.equal(actual, expected) }) +test('consecutive anonymous layers', () => { + let actual = layer_tree(` + @layer {} + @layer {} + `) + let expected = [ + { + name: '__anonymous-1__', + is_anonymous: true, + locations: [{ line: 2, column: 3, start: 3, end: 12 }], + children: [], + }, + { + name: '__anonymous-2__', + is_anonymous: true, + locations: [{ line: 3, column: 3, start: 15, end: 24 }], + children: [], + }, + ] + assert.equal(actual, expected) +}) + test('nested layers with anonymous layers and duplicate names', () => { - let actual = get_tree(` + let actual = layer_tree(` @layer { @layer first {} } @@ -155,17 +190,20 @@ test('nested layers with anonymous layers and duplicate names', () => { let expected = [ { name: '__anonymous-1__', + is_anonymous: true, locations: [{ line: 2, column: 3, start: 3, end: 34 }], children: [ { name: 'first', + is_anonymous: false, children: [], locations: [{ line: 3, column: 4, start: 15, end: 30 }], }, - ] + ], }, { name: 'first', + is_anonymous: false, locations: [{ line: 6, column: 3, start: 38, end: 53 }], children: [], }, diff --git a/vite.config.js b/vite.config.js index 98692b6..c1eedd2 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,20 +6,12 @@ export default defineConfig({ build: { lib: { entry: resolve(__dirname, "src/index.js"), - name: "cssLayerTree", - fileName: "css-layer-tree", + formats: ['es'] }, rollupOptions: { // make sure to externalize deps that shouldn't be bundled // into your library external: ['css-tree'], - output: { - // Provide global variables to use in the UMD build - // for externalized deps - globals: { - 'css-tree': 'csstree', - }, - }, }, }, plugins: [