diff --git a/__tests__/index.spec.ts b/__tests__/index.spec.ts index b12c4a2..70501d2 100644 --- a/__tests__/index.spec.ts +++ b/__tests__/index.spec.ts @@ -10,6 +10,10 @@ describe('node-detective-postcss', () => { assert('@import "foo.css"', ['foo.css']); }); + it('works with single quotes', () => { + assert("@import 'foo.css'", ['foo.css']); + }); + describe('url()', () => { it('works with url()', () => { assert('@import url("navigation.css");', ['navigation.css']); @@ -53,6 +57,14 @@ describe('node-detective-postcss', () => { ); }); + it('ignores protocol-relative URLs', () => { + assert('@import url("//example.com/style.css");', []); + }); + + it('ignores protocol-relative URLs without url()', () => { + assert('@import "//example.com/style.css"', []); + }); + it('does not touch the paths', () => { assert('@import "../../././bla.css"', ['../../././bla.css']); }); @@ -85,6 +97,10 @@ describe('node-detective-postcss', () => { ]); }); + it('ignores absolute URLs', () => { + assert("@value primary from 'https://example.com/colors.css';", []); + }); + it('leaves simple definitions alone', () => { assert('@value mine: #fff;', []); }); @@ -131,6 +147,24 @@ describe('node-detective-postcss', () => { assert('@value x: url(bummer.png)', ['bummer.png'], { url: true }); }); + it('ignores absolute urls', () => { + assert('.x { background: url(https://example.com/img.png) }', [], { + url: true, + }); + }); + + it('ignores protocol-relative urls', () => { + assert('.x { background: url(//example.com/img.png) }', [], { + url: true, + }); + }); + + it('finds multiple url() in one declaration', () => { + assert('.x { background: url(a.png), url(b.png) }', ['a.png', 'b.png'], { + url: true, + }); + }); + it('ignores base64 data: urls', () => { assert( '.x { background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)}', @@ -140,8 +174,8 @@ describe('node-detective-postcss', () => { it('ignores SVG data: urls', () => { const css = `svg { - -webkit-mask-image: url('data:image/svg+xml;utf8,'); - }`; + -webkit-mask-image: url('data:image/svg+xml;utf8,'); + }`; assert(css, []); }); }); diff --git a/package-lock.json b/package-lock.json index 6ce572c..9e77094 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,12 @@ "version": "7.0.1", "license": "MIT", "dependencies": { - "is-url": "^1.2.4", + "is-url-superb": "^4.0.0", "postcss-values-parser": "^6.0.2" }, "devDependencies": { - "@types/is-url": "^1.2.32", "@types/jest": "^30.0.0", + "@types/node": "^18.19.130", "jest": "^30.2.0", "lint-staged": "^16.3.1", "postcss": "^8.5.8", @@ -1117,13 +1117,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/is-url": { - "version": "1.2.32", - "resolved": "https://registry.npmjs.org/@types/is-url/-/is-url-1.2.32.tgz", - "integrity": "sha512-46VLdbWI8Sc+hPexQ6NLNR2YpoDyDZIpASHkJQ2Yr+Kf9Giw6LdCTkwOdsnHKPQeh7xTjTmSnxbE8qpxYuCiHA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1163,13 +1156,13 @@ } }, "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~5.26.4" } }, "node_modules/@types/stack-utils": { @@ -2591,12 +2584,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-url": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", - "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", - "license": "MIT" - }, "node_modules/is-url-superb": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-url-superb/-/is-url-superb-4.0.0.tgz", @@ -4936,9 +4923,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 1b96d27..44f0d51 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,12 @@ "postcss": "^8.4.47" }, "dependencies": { - "is-url": "^1.2.4", + "is-url-superb": "^4.0.0", "postcss-values-parser": "^6.0.2" }, "devDependencies": { - "@types/is-url": "^1.2.32", "@types/jest": "^30.0.0", + "@types/node": "^18.19.130", "jest": "^30.2.0", "lint-staged": "^16.3.1", "postcss": "^8.5.8", @@ -42,7 +42,7 @@ }, "scripts": { "build": "rimraf ./dist && npm run compile", - "compile": "tsc --outDir dist --declaration --declarationDir dist", + "compile": "tsc --project tsconfig.build.json", "prepack": "npm run build", "pretest": "prettier --check .", "test": "jest --ci", diff --git a/src/index.ts b/src/index.ts index c504820..e2453bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { debuglog } from 'util'; -import isUrl = require('is-url'); +import isUrl = require('is-url-superb'); import { parse, AtRule } from 'postcss'; import { parse as postCssParseValue, @@ -14,8 +14,8 @@ import { const debug = debuglog('detective-postcss'); -function detective(src, options: detective.Options = { url: false }) { - let references = []; +function detective(src: string, options: detective.Options = { url: false }) { + let references: string[] = []; let root; try { root = parse(src); @@ -27,14 +27,18 @@ function detective(src, options: detective.Options = { url: false }) { let file = null; if (isImportRule(rule)) { const firstNode = postCssParseValue(rule.params).first; - file = getValueOrUrl(firstNode); - if (file) { - debug('found %s of %s', '@import', file); + if (firstNode) { + file = getValueOrUrl(firstNode); + if (file) { + debug('found %s of %s', '@import', file); + } } } if (isValueRule(rule)) { const lastNode = postCssParseValue(rule.params).last; + if (!lastNode) return; + const prevNode = lastNode.prev(); if (prevNode && isFrom(prevNode)) { @@ -47,7 +51,7 @@ function detective(src, options: detective.Options = { url: false }) { if (options.url && isUrlNode(lastNode)) { file = getValueOrUrl(lastNode); if (file) { - debug('found %s of %s', 'url() with import', file); + debug('found %s of %s', 'url() in @value', file); } } } @@ -61,26 +65,41 @@ function detective(src, options: detective.Options = { url: false }) { const { nodes } = postCssParseValue(decl.value); const files = nodes .filter((node) => isUrlNode(node)) - .map((node) => getValueOrUrl(node)); + .map((node) => getValueOrUrl(node)) + .filter((file): file is string => Boolean(file)); - if (files) { - for (const file of files) { - debug('found %s of %s', 'url() with import', file); - } - - references = references.concat(files); + for (const file of files) { + debug('found %s of %s', 'url() in declaration', file); } + + references = references.concat(files); }); return references; } -function getValueOrUrl(node: ChildNode) { - // ['file'] - const ret = isUrlNode(node) ? getValue(node.nodes[0]) : getValue(node); +function getValueOrUrl(node: ChildNode): string | false { + const ret = isUrlNode(node) ? getUrlContent(node) : getValue(node); + + // is-url-superb uses new URL() which doesn't accept protocol-relative URLs; + // prepend http: so they get correctly identified and filtered out + return !isUrl(ret.startsWith('//') ? `http:${ret}` : ret) && ret; +} + +function getUrlContent(urlNode: Func): string { + const first = urlNode.nodes[0]; + + // Quoted: url('foo.css') or url("foo.css") + if (first && first.type === 'quoted') { + return first.contents; + } - // is-url sometimes gets data: URLs wrong - return !isUrl(ret) && !ret.startsWith('data:') && ret; + // Unquoted: reconstruct the full string from all child nodes (handles + // absolute URLs like url(https://...) which parse as multiple tokens) + return urlNode.nodes + .filter((n) => isNodeWithValue(n)) + .map((n) => getValue(n)) + .join(''); } function getValue(node: ChildNode) { diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..c569cf1 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/tsconfig.json b/tsconfig.json index 9c8f4c9..17d6609 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,9 +2,10 @@ "compilerOptions": { "module": "commonjs", "target": "es2022", + "types": ["node", "jest"], "paths": { "*": ["./src/*"] } }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] }