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();
+ });
+ }
});