Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/Parser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
shellscape marked this conversation as resolved.

- `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

Expand Down Expand Up @@ -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
Expand Down
31 changes: 21 additions & 10 deletions docs/Word.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ If `true`, denotes that the word represents a hexadecimal value.

Type: `Boolean`<br>

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`<br>

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`

Expand Down Expand Up @@ -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
```
14 changes: 12 additions & 2 deletions src/nodes/Word.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,22 @@ 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;
readonly isUrl: boolean = false;
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';

Expand Down Expand Up @@ -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;
}
}
2 changes: 2 additions & 0 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 7 additions & 7 deletions test/snapshots/func.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -3958,7 +3958,7 @@ exports[`function parsing > should parse: url() 3`] = `
],
"isColor": false,
"isHex": false,
"isUrl": false,
"isUrl": true,
"isVariable": false,
"raws": {},
"source": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
88 changes: 88 additions & 0 deletions test/snapshots/word.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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"`;
Expand Down
27 changes: 27 additions & 0 deletions test/word.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Comment on lines +33 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are both mislabeled and incomplete. There are three types of meaningful URL—protocol-absolute, file-absolute, and file-relative—and valid inputs can include both query strings (e.g. ?a=b) and fragment identifiers (e.g. #foo). Procotol-relative URLs, while they did historically exist, are not considered valid by modern browsers, and thus should not be considered IMO. Here is a more comprehensive test matrix:

Suggested change
{ 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' }
{ fixture: 'url(https://example.com/image.png)', label: 'unquoted full URL' },
{ fixture: 'url(/images/image.png)', label: 'unquoted absolute file path' },
{ fixture: 'url(images/image.png)', label: 'unquoted relative file path' },
{ fixture: 'url(./images/image.png)', label: 'unquoted relative file path with leading ./' },
{ fixture: "url('https://example.com/image.png')", label: 'single-quoted full URL' },
{ fixture: "url('/images/image.png')", label: 'single-quoted absolute file path' },
{ fixture: "url('images/image.png')", label: 'single-quoted relative file path' },
{ fixture: "url('./images/image.png')", label: 'single-quoted relative file path with leading ./' },
{ fixture: 'url("https://example.com/image.png")', label: 'double-quoted full URL' },
{ fixture: 'url("/images/image.png")', label: 'double-quoted absolute file path' },
{ fixture: 'url("images/image.png")', label: 'double-quoted relative file path' },
{ fixture: 'url("./images/image.png")', label: 'double-quoted relative file path with leading ./' },
{ fixture: "url('https://example.com/image.png?1234567890#abcdef')", label: 'single-quoted full URL with query and fragment' },
{ fixture: "url('/images/image.png?1234567890#abcdef')", label: 'single-quoted absolute file path with query and fragment' },
{ fixture: "url('images/image.png?1234567890#abcdef')", label: 'single-quoted relative file path with query and fragment' },
{ fixture: "url('./images/image.png?1234567890#abcdef')", label: 'single-quoted relative file path with leading ./, query, and fragment' },
{ fixture: 'url("https://example.com/image.png?1234567890#abcdef")', label: 'double-quoted full URL with query and fragment' },
{ fixture: 'url("/images/image.png?1234567890#abcdef")', label: 'double-quoted absolute file path with query and fragment' },
{ fixture: 'url("images/image.png?1234567890#abcdef")', label: 'double-quoted relative file path with query and fragment' },
{ fixture: 'url("./images/image.png?1234567890#abcdef")', label: 'double-quoted relative file path with leading ./, query, and fragment' },
{ fixture: 'url()', label: 'empty URL value' },
{ fixture: "url('')", label: 'empty single-quoted URL value' },
{ fixture: 'url("")', label: 'empty double-quoted 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();
});
}
});
Loading