detective-postcss
Version:
Detective to find dependents of CSS (PostCSS dialect)
106 lines (105 loc) • 3.54 kB
JavaScript
import { debuglog } from 'node:util';
import isUrl from 'is-url-superb';
import { parse } from 'postcss';
import { parse as postCssParseValue, } from 'postcss-values-parser';
const debug = debuglog('detective-postcss');
export class MalformedCssError extends Error {
}
function detective(src, options = { url: false }) {
let references = [];
let root;
try {
root = parse(src);
}
catch {
throw new MalformedCssError();
}
root.walkAtRules((rule) => {
let file = null;
if (isImportRule(rule)) {
const firstNode = postCssParseValue(rule.params).first;
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)) {
file = getValueOrUrl(lastNode);
if (file) {
debug('found %s of %s', '@value with import', file);
}
}
if (options.url && isUrlNode(lastNode)) {
file = getValueOrUrl(lastNode);
if (file) {
debug('found %s of %s', 'url() in @value', file);
}
}
}
if (file)
references.push(file);
});
if (!options.url)
return references;
root.walkDecls((decl) => {
const { nodes } = postCssParseValue(decl.value);
const files = nodes
.filter((node) => isUrlNode(node))
.map((node) => getValueOrUrl(node))
.filter((file) => Boolean(file));
for (const file of files) {
debug('found %s of %s', 'url() in declaration', file);
}
references.push(...files);
});
return references;
}
function getValueOrUrl(node) {
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) {
const first = urlNode.nodes[0];
// Quoted: url('foo.css') or url("foo.css")
if (first && first.type === 'quoted') {
return first.contents;
}
// 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) {
if (!isNodeWithValue(node)) {
throw new Error('Unexpectedly found a node without a value');
}
return node.type === 'quoted' ? node.contents : node.value;
}
function isNodeWithValue(node) {
return ['word', 'numeric', 'operator', 'punctuation', 'quoted'].includes(node.type);
}
function isUrlNode(node) {
return node.type === 'func' && node.name === 'url';
}
function isValueRule(rule) {
return rule.name === 'value';
}
function isImportRule(rule) {
return rule.name === 'import';
}
function isFrom(node) {
return node.type === 'word' && node.value === 'from';
}
detective.MalformedCssError = MalformedCssError;
export default detective;