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__/**/*"]
}