Repository navigation
url()s containing file paths are not distinguishable from other arbitrary Word nodes #157
Description
Expected Behavior / Situation
It is common in CSS to specify URLs as an absolute or relative path without a scheme, e.g. url(/images/image.png). css-tree parses these as unambiguous Url nodes:
> const csstree = await import("css-tree")
> csstree.parse("url(https://example.com/image.png)", { context: "value" }).children.head.data
{ type: 'Url', loc: null, value: 'https://example.com/image.png' }
> csstree.parse("url(/images/image.png)", { context: "value" }).children.head.data
{ type: 'Url', loc: null, value: '/images/image.png' }It was my expectation that postcss-values-parser would yield Func nodes for both of these inputs, per this documentation.
Actual Behavior / Situation
In reality, postcss-values-parser yields Word nodes in both cases:
> const valuesParser = (await import("postcss-values-parser")).parse
> valuesParser.parse("url(https://example.com/image.png)").nodes[0]
<ref *1> Word {
raws: {},
value: 'https://example.com/image.png',
source: [Object],
isColor: false,
isHex: false,
isUrl: true,
isVariable: false,
type: 'word',
parent: [Root],
Symbol(isClean): false,
Symbol(my): true
}
> valuesParser("url(/images/image.png)").nodes[0]
<ref *1> Word {
raws: {},
value: '/images/image.png',
source: [Object],
isColor: false,
isHex: false,
isUrl: false,
isVariable: false,
type: 'word',
parent: [Root],
Symbol(isClean): false,
Symbol(my): true
}In the first case, isUrl is set as expected, but in the second case, since the value is not actually a valid URL (at least according to is-url-superb, which just uses new URL()—TBH just using URL.parse() or URL.canParse() directly without the dependency would make more sense iff requiring a minimum of Node 18 is possible, IMHO), it remains false and the node is not distinguishable from other non-URL values despite being unambiguous in the css-tree parse.
Modification Proposal
I suggest that the parser should behave according to the documentation, which would result in a parse something like this (hypothetical, not real output):
> valuesParser("url(/images/image.png)").nodes[0]
<ref *1> Func {
raws {},
value: '',
nodes: [
Word {
raws: {},
value: '/images/image.png',
source: [Object],
isColor: false,
isHex: false,
isUrl: false,
isVariable: false,
type: 'word',
parent: [Circular *1],
Symbol(isClean): false,
Symbol(my): true
}
],
source: [Object],
isColor: false,
isVar: false,
name: 'url',
params: '',
type: 'func',
parent: [Root],
Symbol(isClean): false,
Symbol(my): true
}Or, failing that, set isUrl to true for Word nodes created from css-tree Url nodes regardless of whether the contents look like a URL.
Note
My use case is similar to that of rollup-plugin-styler's URL loader, which parses URL and partial URL values in order to perform lookup resolution for bundling.
Activity
shellscape commentedon Apr 22, 2026
Thanks for the issue.
@CharlieHelps please validate the conditions reported in this issue but do not open a pull request or push a branch. You may create a local branch. Write some tests to validate, but do not push them to remote. We're only interested in local validation. This package uses css-tree as well, so identify the branch of code which is transforming a Func into a Word for this case, if that is occurring, and explain the decisions in code around that case. Then reference the CSS specification at https://www.w3.org/TR/CSS2/ and official definition here https://www.w3.org/TR/css/#css, and produce a recommendation for a fix OR non-action for this issue based on your findings.
charliecreates commentedon Apr 22, 2026
Validated locally per request (no PR, no pushed branch).
Reproduction
css-tree.parse("url(https://example.com/image.png)", { context: "value" })→Urlcss-tree.parse("url(/images/image.png)", { context: "value" })→Urlpostcss-values-parser.parse(...)returnsWordfor both.https://...case =>isUrl: true/images/...case =>isUrl: falseWhere this happens in code
src/parser.tsassignWithSource():case 'Url'constructsnew Nodes.Word(...)directly.case 'Function'constructsnew Nodes.Func(...)only when the css-tree node isFunction.Func -> Wordconversion; it is directUrl -> Wordmapping.src/nodes/Word.tssetsisUrlusingis-url-superb(new URL()semantics), which marks absolute URLs true and root-relative paths false.src/stringify.tsprintsWordas plainvalue, sourl(...)wrapper semantics are not preserved on stringify.Local validation tests
test/url.issue-157.test.ts.Funcrepresentation.pnpm vitest run test/url.issue-157.test.tspnpm vitest run test/func.test.ts test/url.issue-157.test.tsSpec references
url()functional notation for URI values): https://www.w3.org/TR/CSS2/syndata.html#uriurl(...): https://www.w3.org/TR/css-syntax-3/#consume-an-ident-like-tokenRecommendation
I recommend taking action (not non-action):
Urlsemantics in parser output.Wordnode shape for compatibility, but setisUrl = truewhen source node type wasUrl(independent of absolute-vs-relative string validation).docs/Word.md, and any conflicting parser docs) to match actual behavior or the new behavior.Urlas a dedicated node orFunc-like shape to preserveurl(...)wrapper semantics during stringify.This addresses the reporter’s relative-path case while minimizing breakage.
gwynne commentedon Apr 22, 2026
For what it's worth, this is the diff I've been using in the meantime. It has so far worked excellently (although I admit, it does a few more things besides fix this issue, like swap out
color-namefor@webref/css, ditchis-url-superbandquote-unquote, bump Typescript to 6.0, and removeComment—I'm not suggesting doing any of those things, they're just what I ended up doing due to being in a pedantic mood 😅). But it does go with the "create a newUrlnode type" solution; I'd be equally fine with theisUrl = truesolution.shellscape commentedon Apr 22, 2026
@CharlieHelps
This suggests removing the need for is-url-superb in the context of the fix, can you confirm?
What is the validity risk surface of removing use of is-url-superb?
Does is-url-superb have accommodations for this scenario?
Is there a specs-compliant logical decision tree we should be following to validate the contents of the
url()function?charliecreates commentedon Apr 22, 2026
@shellscape Yes — in the scope of this fix, that interpretation is correct: if a node originates from a css-tree
Urltoken, settingisUrl = truefrom token provenance removes the need to consultis-url-superbfor that branch.For removing
is-url-superbentirely, the risk surface is broader than #157:Wordnot coming from a css-treeUrltoken (including manually-constructedWordnodes).isUrl(today it effectively means “new URL(value)accepted”, minus variables), so downstream consumers may observe behavior changes.url(/...)case.On accommodations:
is-url-superbis not CSS-url()aware. Its behavior is effectivelynew URL(value)(+ optional lenient host-like fallback), so relative CSS references like/img.png,./img.png,../img.png, and fragment refs like#idare not treated as URL-valid in the CSS sense.A specs-aligned decision tree is:
url-token/<url()>, excludingbad-url-token).isUrl: true) regardless of absolute-vs-relative form.References:
shellscape commentedon Apr 22, 2026
@CharlieHelps does the CSS
urlfunction accept values which do not conform to https://url.spec.whatwg.org/ ? If so, enumerate them.charliecreates commentedon Apr 22, 2026
Yes — CSS
url()is broader than “standalone absolute URLs”. It accepts URL references (including relative forms), not just absolute URL strings.So values that are valid in CSS
url()but often failnew URL(value)(without a base) include:url("image.png"),url("./image.png"),url("../img/image.png")url("/assets/image.png")url("//cdn.example.com/image.png")url("?v=2"),url("img.png?v=2")url("#mask")url("")andurl()(syntactically accepted; defined to resolve to an invalid resource)Nuance: these are not “random non-URL text”; they are URL references that require a base URL context. In other words,
new URL(value, base)is the closer check thannew URL(value).Also, unquoted
url(...)has stricter CSS tokenization rules (raw whitespace/quotes/parentheses can make it invalid); quotedurl("...")is generally safer.References:
gwynne commentedon Apr 22, 2026
The logic I use for URL validation looks like this:
In summary, I use the documented behavior of
URL.parse()vis a vis base URLs to disambiguate the format of the URL while retaining WHATWG parsing semantics. I don't think this package needs the part that separates out the resulting file path from any query string or fragment identifier that may be present, though, nor does it need to distinguish whether the URL was actually a path or not.shellscape commentedon Apr 22, 2026
@CharlieHelps so it looks like anything that sits within the quotes of
url("...")is considered a valid URL by the css values specification, correct?If that is correct, it would be good to signal to the end user if a
WordwithisUrl: trueis a parsable url. WouldisSpecUrlbe a good naming indicator in that case?charliecreates commentedon Apr 22, 2026
@shellscape Yep — that reading is correct.
Per CSS Values 4,
<url()>accepts bothurl(<string>)and<url-token>, sourl("/images/image.png")is syntactically valid. Relative/path URLs are valid CSS URLs and are resolved against the stylesheet base URL, so they won’t necessarily passnew URL(value)without a base.Spec refs:
url(foo)vsurl("foo")): https://www.w3.org/TR/css-syntax-3/#consume-an-ident-like-tokenOn naming:
isSpecUrlis reasonable, but I’d leanisCssUrlfor clarity (it signals “valid in CSS URL grammar”).Then keep parseability separate, e.g.
isParseableUrl(orisAbsoluteUrlif absolute-only is the intent).shellscape commentedon Apr 22, 2026
@CharlieHelps open a new
featPR to address this issue. create a new property onWordnamedisParseableUrland set the value when theWord.isUrl = trueusingis-url-superb. Update the documentation to reflect these changes - be succinct and don't be overly verbose.charliecreates commentedon Apr 22, 2026
Opened feat PR #158: #158
shellscape commentedon Apr 22, 2026
@gwynne the agent misunderstood the assignment here and the PR is no good. I won't have time to circle back to this for a few days, but if you'd like to open a PR in the same spirit, you're welcome to. Though I'm unlikely to accept one that contains as many changes as you had mentioned your branch contained. Minimal blast radius here is preferred.
This is likely to require a major version release since this is technically a breaking modification.