Repository navigation
url()s containing file paths are not distinguishable from other arbitrary Word nodes #157
Description
Activity
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 commented
on Apr 22, 2026 on Apr 22, 2026 – with CharlieCreatesContributorMore actionsValidated 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: false
Where 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.
- So this is not
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
- Added local-only test file:
test/url.issue-157.test.ts. - Confirms current behavior and includes a skipped alternative expectation for
Funcrepresentation. - Ran:
pnpm vitest run test/url.issue-157.test.tspnpm vitest run test/func.test.ts test/url.issue-157.test.ts
- No branch or commit was pushed.
Spec references
- CSS2.1 §4.3.4 URLs and URIs (
url()functional notation for URI values): https://www.w3.org/TR/CSS2/syndata.html#uri - CSS Values 3 §3.4.1 Relative URLs (relative URLs resolve against stylesheet base URL): https://www.w3.org/TR/css-values-3/#relative-urls
- CSS Syntax 3 tokenization for
url(...): https://www.w3.org/TR/css-syntax-3/#consume-an-ident-like-token - CSS Snapshot official definition (points to modern modules): https://www.w3.org/TR/css/#css
Recommendation
I recommend taking action (not non-action):
- Preserve css-tree
Urlsemantics in parser output.- Minimal-risk option: keep
Wordnode shape for compatibility, but setisUrl = truewhen source node type wasUrl(independent of absolute-vs-relative string validation).
- Minimal-risk option: keep
- Update docs (
docs/Word.md, and any conflicting parser docs) to match actual behavior or the new behavior. - Optional larger follow-up (breaking): represent css-tree
Urlas a dedicated node orFunc-like shape to preserveurl(...)wrapper semantics during stringify.
This addresses the reporter’s relative-path case while minimizing breakage.
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.Minimal-risk option: keep Word node shape for compatibility, but set isUrl = true when source node type was Url (independent of absolute-vs-relative string validation).
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 theurl()function?charliecreates commented
on Apr 22, 2026 on Apr 22, 2026 – with CharlieCreatesContributorMore actions@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:- It changes public behavior for any
Wordnot coming from a css-treeUrltoken (including manually-constructedWordnodes). - It changes the current heuristic meaning of
isUrl(today it effectively means “new URL(value)accepted”, minus variables), so downstream consumers may observe behavior changes. - It will require test/snapshot/docs updates beyond the
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:
- Parse/classify by CSS syntax first (
url-token/<url()>, excludingbad-url-token). - If syntax says URL, mark URL-like (
isUrl: true) regardless of absolute-vs-relative form. - If a consumer needs fetch/resolution validity, do that separately (WHATWG URL parsing with stylesheet base URL).
- Keep syntax classification and runtime URL resolvability as separate concerns/flags.
References:
- It changes public behavior for any
@CharlieHelps does the CSS
urlfunction accept values which do not conform to https://url.spec.whatwg.org/ ? If so, enumerate them.charliecreates commented
on Apr 22, 2026 on Apr 22, 2026 – with CharlieCreatesContributorMore actionsYes — 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:- Path-relative:
url("image.png"),url("./image.png"),url("../img/image.png") - Root-relative:
url("/assets/image.png") - Scheme-relative:
url("//cdn.example.com/image.png") - Query-relative:
url("?v=2"),url("img.png?v=2") - Fragment-only:
url("#mask") - Empty URL:
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:
- Path-relative:
The logic I use for URL validation looks like this:
const markerScheme = "x-css-fileurl", markerPath = "/__css-fileurl_relpath/"; const parsedUrl = URL.parse(inputUrl, `${markerScheme}://${markerPath}`); // Not valid as a CSS URL. if (!parsedUrl) { return null; } // - If the `markerPath` is present in the result, the URL was a relative file path; slicing out the // marker entirely results in the correct relative result. // - Otherwise, the URL is either an actual URL or an absolute path. Note that If it's an actual URL, // we won't end up using this value at all. // // Note: The parsedUrl.pathname getter always returns an absolute path regardless of what it was set to. const pathname = parsedUrl.pathname.startsWith(markerPath) ? parsedUrl.pathname.slice(markerPath.length) : parsedUrl.pathname; // If the URL's protocol is the `markerScheme`, the URL was an absolute or relative file path (as // determined above). Otherwise, it's an actual URL. if (parsedUrl.protocol === markerScheme) { return { type: "file", path: pathname, trailing: `${parsedUrl.search}${parsedUrl.hash}` }; } else { return { type: "url", href: parsedUrl.href }; }
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.@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 commented
on Apr 22, 2026 on Apr 22, 2026 – with CharlieCreatesContributorMore actions@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:
- https://www.w3.org/TR/css-values-4/#urls
- https://www.w3.org/TR/css-values-4/#relative-urls
- https://www.w3.org/TR/css-values-4/#url-processing-model
- tokenizer nuance (
url(foo)vsurl("foo")): https://www.w3.org/TR/css-syntax-3/#consume-an-ident-like-token
On 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).@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 commented
on Apr 22, 2026 on Apr 22, 2026 – with CharlieCreatesContributorMore actions@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.
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-treeparses these as unambiguousUrlnodes:It was my expectation that
postcss-values-parserwould yieldFuncnodes for both of these inputs, per this documentation.Actual Behavior / Situation
In reality,
postcss-values-parseryieldsWordnodes in both cases:In the first case,
isUrlis set as expected, but in the second case, since the value is not actually a valid URL (at least according tois-url-superb, which just usesnew URL()—TBH just usingURL.parse()orURL.canParse()directly without the dependency would make more sense iff requiring a minimum of Node 18 is possible, IMHO), it remainsfalseand the node is not distinguishable from other non-URL values despite being unambiguous in thecss-treeparse.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):
Or, failing that, set
isUrltotrueforWordnodes created fromcss-treeUrlnodes 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.