Skip to content

url()s containing file paths are not distinguishable from other arbitrary Word nodes #157

Description

@gwynne

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

  1. shellscape commented on Apr 22, 2026

    @shellscape
    Owner

    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.

  2. charliecreates commented on Apr 22, 2026

    @charliecreates
    Contributor

    Validated locally per request (no PR, no pushed branch).

    Reproduction

    • css-tree.parse("url(https://example.com/image.png)", { context: "value" }) → Url
    • css-tree.parse("url(/images/image.png)", { context: "value" }) → Url
    • postcss-values-parser.parse(...) returns Word for both.
      • https://... case => isUrl: true
      • /images/... case => isUrl: false

    Where this happens in code

    • src/parser.ts assignWithSource():
      • case 'Url' constructs new Nodes.Word(...) directly.
      • case 'Function' constructs new Nodes.Func(...) only when the css-tree node is Function.
    • So this is not Func -> Word conversion; it is direct Url -> Word mapping.
    • src/nodes/Word.ts sets isUrl using is-url-superb (new URL() semantics), which marks absolute URLs true and root-relative paths false.
    • src/stringify.ts prints Word as plain value, so url(...) 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 Func representation.
    • Ran:
      • pnpm vitest run test/url.issue-157.test.ts
      • pnpm vitest run test/func.test.ts test/url.issue-157.test.ts
    • No branch or commit was pushed.

    Spec references

    Recommendation

    I recommend taking action (not non-action):

    1. Preserve css-tree Url semantics in parser output.
      • 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).
    2. Update docs (docs/Word.md, and any conflicting parser docs) to match actual behavior or the new behavior.
    3. Optional larger follow-up (breaking): represent css-tree Url as a dedicated node or Func-like shape to preserve url(...) wrapper semantics during stringify.

    This addresses the reporter’s relative-path case while minimizing breakage.

  3. gwynne commented on Apr 22, 2026

    @gwynne
    Author

    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-name for @webref/css, ditch is-url-superb and quote-unquote, bump Typescript to 6.0, and remove Comment—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 new Url node type" solution; I'd be equally fine with the isUrl = true solution.

  4. shellscape commented on Apr 22, 2026

    @shellscape
    Owner

    @CharlieHelps

    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 the url() function?

  5. charliecreates commented on Apr 22, 2026

    @charliecreates
    Contributor

    @shellscape Yes — in the scope of this fix, that interpretation is correct: if a node originates from a css-tree Url token, setting isUrl = true from token provenance removes the need to consult is-url-superb for that branch.

    For removing is-url-superb entirely, the risk surface is broader than #157:

    • It changes public behavior for any Word not coming from a css-tree Url token (including manually-constructed Word nodes).
    • 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-superb is not CSS-url() aware. Its behavior is effectively new URL(value) (+ optional lenient host-like fallback), so relative CSS references like /img.png, ./img.png, ../img.png, and fragment refs like #id are not treated as URL-valid in the CSS sense.

    A specs-aligned decision tree is:

    1. Parse/classify by CSS syntax first (url-token / <url()>, excluding bad-url-token).
    2. If syntax says URL, mark URL-like (isUrl: true) regardless of absolute-vs-relative form.
    3. If a consumer needs fetch/resolution validity, do that separately (WHATWG URL parsing with stylesheet base URL).
    4. Keep syntax classification and runtime URL resolvability as separate concerns/flags.

    References:

  6. shellscape commented on Apr 22, 2026

    @shellscape
    Owner

    @CharlieHelps does the CSS url function accept values which do not conform to https://url.spec.whatwg.org/ ? If so, enumerate them.

  7. charliecreates commented on Apr 22, 2026

    @charliecreates
    Contributor

    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 fail new URL(value) (without a base) include:

    1. Path-relative: url("image.png"), url("./image.png"), url("../img/image.png")
    2. Root-relative: url("/assets/image.png")
    3. Scheme-relative: url("//cdn.example.com/image.png")
    4. Query-relative: url("?v=2"), url("img.png?v=2")
    5. Fragment-only: url("#mask")
    6. Empty URL: url("") and url() (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 than new URL(value).

    Also, unquoted url(...) has stricter CSS tokenization rules (raw whitespace/quotes/parentheses can make it invalid); quoted url("...") is generally safer.

    References:

  8. gwynne commented on Apr 22, 2026

    @gwynne
    Author

    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.

  9. shellscape commented on Apr 22, 2026

    @shellscape
    Owner

    @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 Word with isUrl: true is a parsable url. Would isSpecUrl be a good naming indicator in that case?

  10. charliecreates commented on Apr 22, 2026

    @charliecreates
    Contributor

    @shellscape Yep — that reading is correct.

    Per CSS Values 4, <url()> accepts both url(<string>) and <url-token>, so url("/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 pass new URL(value) without a base.

    Spec refs:

    On naming: isSpecUrl is reasonable, but I’d lean isCssUrl for clarity (it signals “valid in CSS URL grammar”).
    Then keep parseability separate, e.g. isParseableUrl (or isAbsoluteUrl if absolute-only is the intent).

  11. shellscape commented on Apr 22, 2026

    @shellscape
    Owner

    @CharlieHelps open a new feat PR to address this issue. create a new property on Word named isParseableUrl and set the value when the Word.isUrl = true using is-url-superb. Update the documentation to reflect these changes - be succinct and don't be overly verbose.

  12. charliecreates commented on Apr 22, 2026

    @charliecreates
    Contributor

    Opened feat PR #158: #158

  13. shellscape commented on Apr 22, 2026

    @shellscape
    Owner

    @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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions