From 5d883054adca2b46dd7a5a6d2897a4c48a7e480d Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Wed, 22 Apr 2026 16:56:54 +0000 Subject: [PATCH 1/2] feat(word): add isParseableUrl for url() values --- docs/Parser.md | 6 +++++- docs/Word.md | 26 +++++++++++++++----------- src/nodes/Node.ts | 1 + src/nodes/Word.ts | 8 +++++++- src/parser.ts | 2 ++ test/func.test.ts | 16 ++++++++++++++++ test/snapshots/func.test.ts.snap | 14 +++++++------- 7 files changed, 53 insertions(+), 20 deletions(-) diff --git a/docs/Parser.md b/docs/Parser.md index d29c6b6..fe40b4b 100644 --- a/docs/Parser.md +++ b/docs/Parser.md @@ -60,7 +60,10 @@ Node type mapping: #### URL nodes -When css-tree produces a `Url` node, it is represented as a `Word` node whose `value` is the URL string. URLs inside `url()` appear as a `Func` node named `url`. +When css-tree produces a `Url` node, it is represented as a `Word` node whose `value` is the URL string. For these nodes: + +- `isUrl` is `true` +- `isParseableUrl` reflects whether the URL string is parseable (via `is-url-superb`) #### Fallback Behavior @@ -125,6 +128,7 @@ The parser creates nodes using the NodeOptions interface: ```typescript interface NodeOptions { + isUrl?: boolean; // Internal URL marker used when mapping Url nodes node?: CssNode; // Original css-tree node value?: string; // String value parent?: any; // Parent node diff --git a/docs/Word.md b/docs/Word.md index aa269b6..4db373c 100644 --- a/docs/Word.md +++ b/docs/Word.md @@ -20,7 +20,13 @@ If `true`, denotes that the word represents a hexadecimal value. Type: `Boolean`
-If `true`, denotes that the word represents a Universal Resource Locator (URL). Note that this is only set to `true` for standalone URLs, not for URLs within function calls like `url()`. +If `true`, denotes that the word represents a URL value. This includes URL values that originate from `url(...)`. + +### `isParseableUrl` + +Type: `Boolean`
+ +If `true`, denotes that the word's value is recognized as a parseable URL by [`is-url-superb`](https://www.npmjs.com/package/is-url-superb). ### `isVariable` @@ -52,19 +58,17 @@ The value of the word. ## URL Handling -The Word node has special handling for URLs that appear outside of function contexts. When a standalone URL is encountered in a CSS value, it is parsed as a Word node with the `isUrl` property set to `true`. This is different from URLs that appear within `url()` functions. +URL values are represented as `Word` nodes. Use `isUrl` to determine whether a `Word` is a URL value, and use `isParseableUrl` to determine whether the URL string itself is parseable. ```js import { parse } from 'postcss-values-parser'; -const root = parse('https://example.com'); -const wordNode = root.nodes[0]; - -console.log(wordNode.type); // 'word' -console.log(wordNode.isUrl); // true -console.log(wordNode.value); // 'https://example.com' -``` - -Note: URLs within `url()` functions are handled differently and create `Func` nodes instead of `Word` nodes. +const absolute = parse('url(https://example.com/image.png)').nodes[0]; +const relative = parse('url(/images/image.png)').nodes[0]; +console.log(absolute.isUrl); // true +console.log(absolute.isParseableUrl); // true +console.log(relative.isUrl); // true +console.log(relative.isParseableUrl); // false +``` diff --git a/src/nodes/Node.ts b/src/nodes/Node.ts index 64088aa..3f86663 100644 --- a/src/nodes/Node.ts +++ b/src/nodes/Node.ts @@ -14,6 +14,7 @@ import { Input, Node as PostCssNode } from 'postcss'; import { stringify } from '../stringify.js'; export interface NodeOptions { + isUrl?: boolean; node?: CssNode; value?: string; parent?: any; diff --git a/src/nodes/Word.ts b/src/nodes/Word.ts index ae62ff0..ec13333 100644 --- a/src/nodes/Word.ts +++ b/src/nodes/Word.ts @@ -24,6 +24,10 @@ export class Word extends Node { readonly isVariable: boolean = false; declare type: string; + get isParseableUrl(): boolean { + return !this.isVariable && isUrl(this.value); + } + constructor(options: NodeOptions) { super(options); this.type = 'word'; @@ -58,7 +62,9 @@ export class Word extends Node { // Determine word properties this.isHex = reHex.test(value); this.isVariable = reVariable.test(value); - this.isUrl = !this.isVariable && isUrl(value); + const parseableUrl = !this.isVariable && isUrl(value); + + this.isUrl = Boolean(options && options.isUrl) || parseableUrl; this.isColor = this.isHex || (colorNames as any)[value.toLowerCase()] !== undefined; } } diff --git a/src/parser.ts b/src/parser.ts index afaace0..29af486 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -53,6 +53,7 @@ const assign = (parent: Nodes.Container | Nodes.Root, nodes: CssNode[]) => { case 'Url': // Create a Word node for URL with the URL value for toString() newNode = new Nodes.Word({ + isUrl: true, node: { ...node, type: 'Identifier' as any, @@ -183,6 +184,7 @@ export const parse = (css: string, _opts?: ParseOptions) => { case 'Url': // Create a Word node for URL with the URL value for toString() newNode = new Nodes.Word({ + isUrl: true, node: { ...nodeOptions.node, type: 'Identifier' as any, diff --git a/test/func.test.ts b/test/func.test.ts index 44c5a71..e77005b 100644 --- a/test/func.test.ts +++ b/test/func.test.ts @@ -34,4 +34,20 @@ describe('function parsing', () => { expect(() => parse(fixture)).toThrow(); }); } + + it('should mark absolute url() words as parseable URLs', () => { + const root = parse('url(https://example.com/image.png)'); + const node = root.first as any; + + expect(node.isUrl).toBe(true); + expect(node.isParseableUrl).toBe(true); + }); + + it('should mark relative url() words as URL words but not parseable URLs', () => { + const root = parse('url(/images/image.png)'); + const node = root.first as any; + + expect(node.isUrl).toBe(true); + expect(node.isParseableUrl).toBe(false); + }); }); diff --git a/test/snapshots/func.test.ts.snap b/test/snapshots/func.test.ts.snap index 49f251d..d39984e 100644 --- a/test/snapshots/func.test.ts.snap +++ b/test/snapshots/func.test.ts.snap @@ -3730,7 +3730,7 @@ exports[`function parsing > should parse: url( "/gfx/img/bg.jpg" ) 3`] = ` ], "isColor": false, "isHex": false, - "isUrl": false, + "isUrl": true, "isVariable": false, "raws": {}, "source": { @@ -3806,7 +3806,7 @@ exports[`function parsing > should parse: url( '/gfx/img/bg.jpg' ) 3`] = ` ], "isColor": false, "isHex": false, - "isUrl": false, + "isUrl": true, "isVariable": false, "raws": {}, "source": { @@ -3882,7 +3882,7 @@ exports[`function parsing > should parse: url( /gfx/img/bg.jpg 3`] = ` ], "isColor": false, "isHex": false, - "isUrl": false, + "isUrl": true, "isVariable": false, "raws": {}, "source": { @@ -3920,7 +3920,7 @@ exports[`function parsing > should parse: url( /gfx/img/bg.jpg ) 3`] = ` ], "isColor": false, "isHex": false, - "isUrl": false, + "isUrl": true, "isVariable": false, "raws": {}, "source": { @@ -3958,7 +3958,7 @@ exports[`function parsing > should parse: url() 3`] = ` ], "isColor": false, "isHex": false, - "isUrl": false, + "isUrl": true, "isVariable": false, "raws": {}, "source": { @@ -3996,7 +3996,7 @@ exports[`function parsing > should parse: url() foo bar baz 3`] = ` ], "isColor": false, "isHex": false, - "isUrl": false, + "isUrl": true, "isVariable": false, "raws": {}, "source": { @@ -4121,7 +4121,7 @@ exports[`function parsing > should parse: url(//123.example.com) 3`] = ` ], "isColor": false, "isHex": false, - "isUrl": false, + "isUrl": true, "isVariable": false, "raws": {}, "source": { From 6ba896cbf60fb9a633eba963d149a57e7e1d9d32 Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Wed, 22 Apr 2026 19:49:41 +0000 Subject: [PATCH 2/2] fix(word): apply URL review feedback for PR 158 --- docs/Parser.md | 7 ++- docs/Word.md | 13 +++-- src/nodes/Node.ts | 1 - src/nodes/Word.ts | 8 ++- src/parser.ts | 4 +- test/func.test.ts | 16 ------ test/snapshots/word.test.ts.snap | 88 ++++++++++++++++++++++++++++++++ test/word.test.ts | 27 ++++++++++ 8 files changed, 139 insertions(+), 25 deletions(-) diff --git a/docs/Parser.md b/docs/Parser.md index fe40b4b..f6b64e9 100644 --- a/docs/Parser.md +++ b/docs/Parser.md @@ -65,6 +65,8 @@ When css-tree produces a `Url` node, it is represented as a `Word` node whose `v - `isUrl` is `true` - `isParseableUrl` reflects whether the URL string is parseable (via `is-url-superb`) +Both `url(https://google.com)` and `url('https://google.com')` normalize to the same `Word` value (`https://google.com`) because css-tree emits the same `Url` node shape for quoted and unquoted forms. + #### Fallback Behavior Unknown or unrecognized node types are parsed as `Word` nodes to ensure the parser doesn't fail on unexpected input. @@ -128,11 +130,14 @@ The parser creates nodes using the NodeOptions interface: ```typescript interface NodeOptions { - isUrl?: boolean; // Internal URL marker used when mapping Url nodes node?: CssNode; // Original css-tree node value?: string; // String value parent?: any; // Parent node } + +interface WordOptions extends NodeOptions { + fromUrlFunc?: boolean; // Internal marker for Word nodes mapped from css-tree Url +} ``` ### Recursive Parsing diff --git a/docs/Word.md b/docs/Word.md index 4db373c..44fc79d 100644 --- a/docs/Word.md +++ b/docs/Word.md @@ -60,14 +60,21 @@ The value of the word. URL values are represented as `Word` nodes. Use `isUrl` to determine whether a `Word` is a URL value, and use `isParseableUrl` to determine whether the URL string itself is parseable. +For `url(...)`, quoted and unquoted absolute URLs normalize to the same `value`. + ```js import { parse } from 'postcss-values-parser'; -const absolute = parse('url(https://example.com/image.png)').nodes[0]; +const unquotedAbsolute = parse('url(https://example.com/image.png)').nodes[0]; +const quotedAbsolute = parse("url('https://example.com/image.png')").nodes[0]; const relative = parse('url(/images/image.png)').nodes[0]; -console.log(absolute.isUrl); // true -console.log(absolute.isParseableUrl); // true +console.log(unquotedAbsolute.value === quotedAbsolute.value); // true +console.log(unquotedAbsolute.isUrl); // true +console.log(unquotedAbsolute.isParseableUrl); // true + +console.log(quotedAbsolute.isUrl); // true +console.log(quotedAbsolute.isParseableUrl); // true console.log(relative.isUrl); // true console.log(relative.isParseableUrl); // false diff --git a/src/nodes/Node.ts b/src/nodes/Node.ts index 3f86663..64088aa 100644 --- a/src/nodes/Node.ts +++ b/src/nodes/Node.ts @@ -14,7 +14,6 @@ import { Input, Node as PostCssNode } from 'postcss'; import { stringify } from '../stringify.js'; export interface NodeOptions { - isUrl?: boolean; node?: CssNode; value?: string; parent?: any; diff --git a/src/nodes/Word.ts b/src/nodes/Word.ts index ec13333..71d99f7 100644 --- a/src/nodes/Word.ts +++ b/src/nodes/Word.ts @@ -17,6 +17,10 @@ import { Node, NodeOptions } from './Node.js'; const reHex = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; const reVariable = /^--/; +export interface WordOptions extends NodeOptions { + fromUrlFunc?: boolean; +} + export class Word extends Node { readonly isColor: boolean = false; readonly isHex: boolean = false; @@ -28,7 +32,7 @@ export class Word extends Node { return !this.isVariable && isUrl(this.value); } - constructor(options: NodeOptions) { + constructor(options: WordOptions) { super(options); this.type = 'word'; @@ -64,7 +68,7 @@ export class Word extends Node { this.isVariable = reVariable.test(value); const parseableUrl = !this.isVariable && isUrl(value); - this.isUrl = Boolean(options && options.isUrl) || parseableUrl; + this.isUrl = Boolean(options && options.fromUrlFunc) || parseableUrl; this.isColor = this.isHex || (colorNames as any)[value.toLowerCase()] !== undefined; } } diff --git a/src/parser.ts b/src/parser.ts index 29af486..180f771 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -53,7 +53,7 @@ const assign = (parent: Nodes.Container | Nodes.Root, nodes: CssNode[]) => { case 'Url': // Create a Word node for URL with the URL value for toString() newNode = new Nodes.Word({ - isUrl: true, + fromUrlFunc: true, node: { ...node, type: 'Identifier' as any, @@ -184,7 +184,7 @@ export const parse = (css: string, _opts?: ParseOptions) => { case 'Url': // Create a Word node for URL with the URL value for toString() newNode = new Nodes.Word({ - isUrl: true, + fromUrlFunc: true, node: { ...nodeOptions.node, type: 'Identifier' as any, diff --git a/test/func.test.ts b/test/func.test.ts index e77005b..44c5a71 100644 --- a/test/func.test.ts +++ b/test/func.test.ts @@ -34,20 +34,4 @@ describe('function parsing', () => { expect(() => parse(fixture)).toThrow(); }); } - - it('should mark absolute url() words as parseable URLs', () => { - const root = parse('url(https://example.com/image.png)'); - const node = root.first as any; - - expect(node.isUrl).toBe(true); - expect(node.isParseableUrl).toBe(true); - }); - - it('should mark relative url() words as URL words but not parseable URLs', () => { - const root = parse('url(/images/image.png)'); - const node = root.first as any; - - expect(node.isUrl).toBe(true); - expect(node.isParseableUrl).toBe(false); - }); }); diff --git a/test/snapshots/word.test.ts.snap b/test/snapshots/word.test.ts.snap index b8d09f5..15106f7 100644 --- a/test/snapshots/word.test.ts.snap +++ b/test/snapshots/word.test.ts.snap @@ -1,5 +1,93 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`word parsing > should expose URL metadata for: double-quoted absolute URL 1`] = ` +{ + "input": "url("https://example.com/image.png")", + "isParseableUrl": true, + "isUrl": true, + "output": "https://example.com/image.png", + "type": "word", + "value": "https://example.com/image.png", +} +`; + +exports[`word parsing > should expose URL metadata for: double-quoted relative URL 1`] = ` +{ + "input": "url("/images/image.png")", + "isParseableUrl": false, + "isUrl": true, + "output": "/images/image.png", + "type": "word", + "value": "/images/image.png", +} +`; + +exports[`word parsing > should expose URL metadata for: empty URL value 1`] = ` +{ + "input": "url()", + "isParseableUrl": false, + "isUrl": true, + "output": "", + "type": "word", + "value": "", +} +`; + +exports[`word parsing > should expose URL metadata for: protocol-relative URL 1`] = ` +{ + "input": "url(//cdn.example.com/image.png)", + "isParseableUrl": false, + "isUrl": true, + "output": "//cdn.example.com/image.png", + "type": "word", + "value": "//cdn.example.com/image.png", +} +`; + +exports[`word parsing > should expose URL metadata for: single-quoted absolute URL 1`] = ` +{ + "input": "url('https://example.com/image.png')", + "isParseableUrl": true, + "isUrl": true, + "output": "https://example.com/image.png", + "type": "word", + "value": "https://example.com/image.png", +} +`; + +exports[`word parsing > should expose URL metadata for: single-quoted relative URL 1`] = ` +{ + "input": "url('/images/image.png')", + "isParseableUrl": false, + "isUrl": true, + "output": "/images/image.png", + "type": "word", + "value": "/images/image.png", +} +`; + +exports[`word parsing > should expose URL metadata for: unquoted absolute URL 1`] = ` +{ + "input": "url(https://example.com/image.png)", + "isParseableUrl": true, + "isUrl": true, + "output": "https://example.com/image.png", + "type": "word", + "value": "https://example.com/image.png", +} +`; + +exports[`word parsing > should expose URL metadata for: unquoted relative URL 1`] = ` +{ + "input": "url(/images/image.png)", + "isParseableUrl": false, + "isUrl": true, + "output": "/images/image.png", + "type": "word", + "value": "/images/image.png", +} +`; + exports[`word parsing > should parse: \\"word\\" \\s 1`] = `"\\"word\\""`; exports[`word parsing > should parse: \\"word\\" \\s 2`] = `"\\"word\\" \\s"`; diff --git a/test/word.test.ts b/test/word.test.ts index 3b9456f..0d45be2 100644 --- a/test/word.test.ts +++ b/test/word.test.ts @@ -28,4 +28,31 @@ describe('word parsing', () => { expect(nodes).toMatchSnapshot(); }); } + + const urlFixtures = [ + { fixture: 'url(https://example.com/image.png)', label: 'unquoted absolute URL' }, + { fixture: "url('https://example.com/image.png')", label: 'single-quoted absolute URL' }, + { fixture: 'url("https://example.com/image.png")', label: 'double-quoted absolute URL' }, + { fixture: 'url(/images/image.png)', label: 'unquoted relative URL' }, + { fixture: "url('/images/image.png')", label: 'single-quoted relative URL' }, + { fixture: 'url("/images/image.png")', label: 'double-quoted relative URL' }, + { fixture: 'url(//cdn.example.com/image.png)', label: 'protocol-relative URL' }, + { fixture: 'url()', label: 'empty URL value' } + ]; + + for (const { fixture, label } of urlFixtures) { + it(`should expose URL metadata for: ${label}`, () => { + const root = parse(fixture); + const node = root.first as any; + + expect({ + input: fixture, + output: nodeToString(root), + type: node.type, + value: node.value, + isUrl: node.isUrl, + isParseableUrl: node.isParseableUrl + }).toMatchSnapshot(); + }); + } });