diff --git a/docs/Parser.md b/docs/Parser.md index d29c6b6..f6b64e9 100644 --- a/docs/Parser.md +++ b/docs/Parser.md @@ -60,7 +60,12 @@ 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`) + +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 @@ -129,6 +134,10 @@ interface NodeOptions { 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 aa269b6..44fc79d 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,24 @@ 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. + +For `url(...)`, quoted and unquoted absolute URLs normalize to the same `value`. ```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' -``` +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]; -Note: URLs within `url()` functions are handled differently and create `Func` nodes instead of `Word` nodes. +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/Word.ts b/src/nodes/Word.ts index ae62ff0..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; @@ -24,7 +28,11 @@ export class Word extends Node { readonly isVariable: boolean = false; declare type: string; - constructor(options: NodeOptions) { + get isParseableUrl(): boolean { + return !this.isVariable && isUrl(this.value); + } + + constructor(options: WordOptions) { super(options); this.type = 'word'; @@ -58,7 +66,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.fromUrlFunc) || parseableUrl; this.isColor = this.isHex || (colorNames as any)[value.toLowerCase()] !== undefined; } } diff --git a/src/parser.ts b/src/parser.ts index afaace0..180f771 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({ + fromUrlFunc: 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({ + fromUrlFunc: true, node: { ...nodeOptions.node, type: 'Identifier' as any, 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": { 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(); + }); + } });