Skip to content

Commit

Permalink
postcss-rewrite-url: url modifiers and docs (#1526)
Browse files Browse the repository at this point in the history
  • Loading branch information
romainmenke authored Nov 30, 2024
1 parent b22c242 commit e3f064c
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 27 deletions.
5 changes: 5 additions & 0 deletions plugins/postcss-rewrite-url/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changes to PostCSS Rewrite URL

### Unreleased (minor)

- Add support for rewriting url modifiers (e.g. `rewrite-url('foo.png' --foo)` -> `rewrite-url('foo.png#foo')`)
- Document the syntax definition for `rewrite-url()`

### 2.0.4

_November 1, 2024_
Expand Down
63 changes: 57 additions & 6 deletions plugins/postcss-rewrite-url/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,42 +71,93 @@ instructions for:
Determine how urls are rewritten with the `rewriter` callback.

```ts
export interface ValueToRewrite {
url: string
interface ValueToRewrite {
url: string;
urlModifiers: Array<string>;
}

export interface RewriteContext {
interface RewriteContext {
type: 'declaration-value' | 'at-rule-prelude';
from: string | undefined;
rootFrom: string | undefined;
property?: string;
atRuleName?: string;
}

export type Rewriter = (value: ValueToRewrite, context: RewriteContext) => ValueToRewrite | false;
type Rewriter = (value: ValueToRewrite, context: RewriteContext) => ValueToRewrite | false;

/** postcss-rewrite-url plugin options */
export type pluginOptions = {
type pluginOptions = {
rewriter: Rewriter;
};
```

```js
postcssRewriteURL({
rewriter: (value, context) => {
console.log(value); // info about the `rewrite-url()` function itself (e.g. the url and url modifiers)
console.log(context); // context surrounding the `rewrite-url()` function (i.e. where was it found?)

if (value.url === 'ignore-me') {
// return `false` to ignore this url and preserve `rewrite-url()` in the output
return false;
}

console.log(context); // for extra conditional logic
// use url modifiers to trigger specific behavior
if (value.urlModifiers.includes('--a-custom-modifier')) {
return {
url: value.url + '#other-modification',
urlModifiers: [], // pass new or existing url modifiers to emit these in the final result
};
}

return {
url: value.url + '#modified',
};
},
})
```

## Syntax

[PostCSS Rewrite URL] is non-standard and is not part of any official CSS Specification.

### `rewrite-url()` function

The `rewrite-url()` function takes a url string and optional url modifiers and will be transformed to a standard `url()` function by a dev tool.

```css
.foo {
background: rewrite-url('foo.png');
}
```

```
rewrite-url() = rewrite-url( <string> <url-modifier>* )
```

#### [Stylelint](https://stylelint.io/user-guide/rules/declaration-property-value-no-unknown/#propertiessyntax--property-syntax-)

Stylelint is able to check for unknown property values.
Setting the correct configuration for this rule makes it possible to check even non-standard syntax.

```js
'declaration-property-value-no-unknown': [
true,
{
"typesSyntax": {
"url": "| rewrite-url( <string> <url-modifier>* )"
}
},
],
'function-no-unknown': [
true,
{
"ignoreFunctions": ["rewrite-url"]
}
],
```

[cli-url]: https://github.com/csstools/postcss-plugins/actions/workflows/test.yml?query=workflow/test

[discord]: https://discord.gg/bUadyRwkJS
Expand Down
2 changes: 1 addition & 1 deletion plugins/postcss-rewrite-url/dist/index.cjs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
"use strict";var e=require("@csstools/css-tokenizer"),r=require("@csstools/css-parser-algorithms");function serializeString(e){let r="";for(const t of e){const e=t.codePointAt(0);if(void 0!==e)switch(e){case 0:r+=String.fromCodePoint(65533);break;case 127:r+=`\\${e.toString(16)}`;break;case 34:case 39:case 92:r+=`\\${t}`;break;default:if(1<=e&&e<=31){r+=`\\${e.toString(16)} `;break}r+=t}else r+=String.fromCodePoint(65533)}return r}const t=/rewrite-url\(/i,o=/^rewrite-url$/i,creator=e=>{const r=e?.rewriter??(e=>e);return{postcssPlugin:"postcss-rewrite-url",Once(e,{result:t}){e.walkDecls((e=>{rewriteDeclaration(e,t,r)})),e.walkAtRules((e=>{rewriteAtRule(e,t,r)}))},Declaration(e,{result:t}){rewriteDeclaration(e,t,r)},AtRule(e,{result:t}){rewriteAtRule(e,t,r)}}};function rewriteDeclaration(e,r,o){if(!t.test(e.value))return;const s={type:"declaration-value",rootFrom:r.opts.from,from:e.source?.input.from,property:e.prop},i=rewrite(o,e.value,s);i!==e.value&&(e.value=i)}function rewriteAtRule(e,r,o){if(!t.test(e.params))return;const s={type:"at-rule-prelude",rootFrom:r.opts.from,from:e.source?.input.from,atRuleName:e.name},i=rewrite(o,e.params,s);i!==e.params&&(e.params=i)}function rewrite(t,s,i){const a=r.parseCommaSeparatedListOfComponentValues(e.tokenize({css:s})),n=r.replaceComponentValues(a,(s=>{if(r.isFunctionNode(s)&&o.test(s.getName()))for(const o of s.value)if(!r.isWhitespaceNode(o)&&!r.isCommentNode(o)&&r.isTokenNode(o)&&e.isTokenString(o.value)){const e=o.value[4].value.trim(),r=t({url:e},i);if(!1===r)return;if(r.url===e)break;return o.value[4].value=r.url,o.value[1]=`"${serializeString(r.url)}"`,s.name[1]="url(",s.name[4].value="url",s}}));return r.stringify(n)}creator.postcss=!0,module.exports=creator;
"use strict";var e=require("@csstools/css-tokenizer"),r=require("@csstools/css-parser-algorithms");function serializeString(e){let r="";for(const t of e){const e=t.codePointAt(0);if(void 0!==e)switch(e){case 0:r+=String.fromCodePoint(65533);break;case 127:r+=`\\${e.toString(16)}`;break;case 34:case 39:case 92:r+=`\\${t}`;break;default:if(1<=e&&e<=31){r+=`\\${e.toString(16)} `;break}r+=t}else r+=String.fromCodePoint(65533)}return r}const t=/rewrite-url\(/i,o=/^rewrite-url$/i,creator=e=>{const r=e?.rewriter??(e=>e);return{postcssPlugin:"postcss-rewrite-url",Once(e,{result:t}){e.walkDecls((e=>{rewriteDeclaration(e,t,r)})),e.walkAtRules((e=>{rewriteAtRule(e,t,r)}))},Declaration(e,{result:t}){rewriteDeclaration(e,t,r)},AtRule(e,{result:t}){rewriteAtRule(e,t,r)}}};function rewriteDeclaration(e,r,o){if(!t.test(e.value))return;const s={type:"declaration-value",rootFrom:r.opts.from,from:e.source?.input.from,property:e.prop},i=rewrite(o,e.value,s);i!==e.value&&(e.value=i)}function rewriteAtRule(e,r,o){if(!t.test(e.params))return;const s={type:"at-rule-prelude",rootFrom:r.opts.from,from:e.source?.input.from,atRuleName:e.name},i=rewrite(o,e.params,s);i!==e.params&&(e.params=i)}function rewrite(t,s,i){const n=r.parseCommaSeparatedListOfComponentValues(e.tokenize({css:s})),a=r.replaceComponentValues(n,(s=>{if(!r.isFunctionNode(s)||!o.test(s.getName()))return;const n=s.value.filter((e=>!r.isWhiteSpaceOrCommentNode(e)));for(let o=0;o<n.length;o++){const a=n[o];if(r.isTokenNode(a)&&e.isTokenString(a.value)){const u=a.value[4].value.trim(),l=n.slice(o+1),c=t({url:u,urlModifiers:l.map((e=>e.toString()))},i);if(!1===c)return;const m=r.parseListOfComponentValues(e.tokenize({css:[`"${serializeString(c.url)}"`,...c.urlModifiers??l].join(" ")}));return s.value=m,s.name[1]="url(",s.name[4].value="url",s}return}}));return r.stringify(a)}creator.postcss=!0,module.exports=creator;
1 change: 1 addition & 0 deletions plugins/postcss-rewrite-url/dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export declare type Rewriter = (value: ValueToRewrite, context: RewriteContext)

export declare interface ValueToRewrite {
url: string;
urlModifiers: Array<string>;
}

export { }
2 changes: 1 addition & 1 deletion plugins/postcss-rewrite-url/dist/index.mjs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
import{tokenize as r,isTokenString as e}from"@csstools/css-tokenizer";import{parseCommaSeparatedListOfComponentValues as t,replaceComponentValues as o,isFunctionNode as s,isWhitespaceNode as a,isCommentNode as i,isTokenNode as l,stringify as u}from"@csstools/css-parser-algorithms";function serializeString(r){let e="";for(const t of r){const r=t.codePointAt(0);if(void 0!==r)switch(r){case 0:e+=String.fromCodePoint(65533);break;case 127:e+=`\\${r.toString(16)}`;break;case 34:case 39:case 92:e+=`\\${t}`;break;default:if(1<=r&&r<=31){e+=`\\${r.toString(16)} `;break}e+=t}else e+=String.fromCodePoint(65533)}return e}const n=/rewrite-url\(/i,c=/^rewrite-url$/i,creator=r=>{const e=r?.rewriter??(r=>r);return{postcssPlugin:"postcss-rewrite-url",Once(r,{result:t}){r.walkDecls((r=>{rewriteDeclaration(r,t,e)})),r.walkAtRules((r=>{rewriteAtRule(r,t,e)}))},Declaration(r,{result:t}){rewriteDeclaration(r,t,e)},AtRule(r,{result:t}){rewriteAtRule(r,t,e)}}};function rewriteDeclaration(r,e,t){if(!n.test(r.value))return;const o={type:"declaration-value",rootFrom:e.opts.from,from:r.source?.input.from,property:r.prop},s=rewrite(t,r.value,o);s!==r.value&&(r.value=s)}function rewriteAtRule(r,e,t){if(!n.test(r.params))return;const o={type:"at-rule-prelude",rootFrom:e.opts.from,from:r.source?.input.from,atRuleName:r.name},s=rewrite(t,r.params,o);s!==r.params&&(r.params=s)}function rewrite(n,f,m){const p=t(r({css:f})),w=o(p,(r=>{if(s(r)&&c.test(r.getName()))for(const t of r.value)if(!a(t)&&!i(t)&&l(t)&&e(t.value)){const e=t.value[4].value.trim(),o=n({url:e},m);if(!1===o)return;if(o.url===e)break;return t.value[4].value=o.url,t.value[1]=`"${serializeString(o.url)}"`,r.name[1]="url(",r.name[4].value="url",r}}));return u(w)}creator.postcss=!0;export{creator as default};
import{tokenize as r,isTokenString as e}from"@csstools/css-tokenizer";import{parseCommaSeparatedListOfComponentValues as t,replaceComponentValues as o,isFunctionNode as s,isWhiteSpaceOrCommentNode as i,isTokenNode as a,parseListOfComponentValues as l,stringify as n}from"@csstools/css-parser-algorithms";function serializeString(r){let e="";for(const t of r){const r=t.codePointAt(0);if(void 0!==r)switch(r){case 0:e+=String.fromCodePoint(65533);break;case 127:e+=`\\${r.toString(16)}`;break;case 34:case 39:case 92:e+=`\\${t}`;break;default:if(1<=r&&r<=31){e+=`\\${r.toString(16)} `;break}e+=t}else e+=String.fromCodePoint(65533)}return e}const u=/rewrite-url\(/i,c=/^rewrite-url$/i,creator=r=>{const e=r?.rewriter??(r=>r);return{postcssPlugin:"postcss-rewrite-url",Once(r,{result:t}){r.walkDecls((r=>{rewriteDeclaration(r,t,e)})),r.walkAtRules((r=>{rewriteAtRule(r,t,e)}))},Declaration(r,{result:t}){rewriteDeclaration(r,t,e)},AtRule(r,{result:t}){rewriteAtRule(r,t,e)}}};function rewriteDeclaration(r,e,t){if(!u.test(r.value))return;const o={type:"declaration-value",rootFrom:e.opts.from,from:r.source?.input.from,property:r.prop},s=rewrite(t,r.value,o);s!==r.value&&(r.value=s)}function rewriteAtRule(r,e,t){if(!u.test(r.params))return;const o={type:"at-rule-prelude",rootFrom:e.opts.from,from:r.source?.input.from,atRuleName:r.name},s=rewrite(t,r.params,o);s!==r.params&&(r.params=s)}function rewrite(u,f,m){const p=t(r({css:f})),w=o(p,(t=>{if(!s(t)||!c.test(t.getName()))return;const o=t.value.filter((r=>!i(r)));for(let s=0;s<o.length;s++){const i=o[s];if(a(i)&&e(i.value)){const e=i.value[4].value.trim(),a=o.slice(s+1),n=u({url:e,urlModifiers:a.map((r=>r.toString()))},m);if(!1===n)return;const c=l(r({css:[`"${serializeString(n.url)}"`,...n.urlModifiers??a].join(" ")}));return t.value=c,t.name[1]="url(",t.name[4].value="url",t}return}}));return n(w)}creator.postcss=!0;export{creator as default};
63 changes: 57 additions & 6 deletions plugins/postcss-rewrite-url/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,40 +38,91 @@
Determine how urls are rewritten with the `rewriter` callback.

```ts
export interface ValueToRewrite {
url: string
interface ValueToRewrite {
url: string;
urlModifiers: Array<string>;
}

export interface RewriteContext {
interface RewriteContext {
type: 'declaration-value' | 'at-rule-prelude';
from: string | undefined;
rootFrom: string | undefined;
property?: string;
atRuleName?: string;
}

export type Rewriter = (value: ValueToRewrite, context: RewriteContext) => ValueToRewrite | false;
type Rewriter = (value: ValueToRewrite, context: RewriteContext) => ValueToRewrite | false;

/** postcss-rewrite-url plugin options */
export type pluginOptions = {
type pluginOptions = {
rewriter: Rewriter;
};
```

```js
<exportName>({
rewriter: (value, context) => {
console.log(value); // info about the `rewrite-url()` function itself (e.g. the url and url modifiers)
console.log(context); // context surrounding the `rewrite-url()` function (i.e. where was it found?)

if (value.url === 'ignore-me') {
// return `false` to ignore this url and preserve `rewrite-url()` in the output
return false;
}

console.log(context); // for extra conditional logic
// use url modifiers to trigger specific behavior
if (value.urlModifiers.includes('--a-custom-modifier')) {
return {
url: value.url + '#other-modification',
urlModifiers: [], // pass new or existing url modifiers to emit these in the final result
};
}

return {
url: value.url + '#modified',
};
},
})
```

## Syntax

[<humanReadableName>] is non-standard and is not part of any official CSS Specification.

### `rewrite-url()` function

The `rewrite-url()` function takes a url string and optional url modifiers and will be transformed to a standard `url()` function by a dev tool.

```css
.foo {
background: rewrite-url('foo.png');
}
```

```
rewrite-url() = rewrite-url( <string> <url-modifier>* )
```

#### [Stylelint](https://stylelint.io/user-guide/rules/declaration-property-value-no-unknown/#propertiessyntax--property-syntax-)

Stylelint is able to check for unknown property values.
Setting the correct configuration for this rule makes it possible to check even non-standard syntax.

```js
'declaration-property-value-no-unknown': [
true,
{
"typesSyntax": {
"url": "| rewrite-url( <string> <url-modifier>* )"
}
},
],
'function-no-unknown': [
true,
{
"ignoreFunctions": ["rewrite-url"]
}
],
```

<linkList>
34 changes: 21 additions & 13 deletions plugins/postcss-rewrite-url/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { AtRule, Declaration, PluginCreator, Result } from 'postcss';
import { isTokenString, tokenize } from '@csstools/css-tokenizer';
import { isCommentNode, isFunctionNode, isTokenNode, isWhitespaceNode, parseCommaSeparatedListOfComponentValues, replaceComponentValues, stringify } from '@csstools/css-parser-algorithms';
import { isFunctionNode, isTokenNode, isWhiteSpaceOrCommentNode, parseCommaSeparatedListOfComponentValues, parseListOfComponentValues, replaceComponentValues, stringify } from '@csstools/css-parser-algorithms';
import { serializeString } from './serialize-string';

export interface ValueToRewrite {
url: string
url: string;
urlModifiers: Array<string>;
}

export interface RewriteContext {
Expand Down Expand Up @@ -102,30 +103,37 @@ function rewrite(rewriter: Rewriter, value: string, context: RewriteContext): st
return;
}

for (const x of componentValue.value) {
if (isWhitespaceNode(x) || isCommentNode(x)) {
continue;
}
const rewriteArguments = componentValue.value.filter((x) => !isWhiteSpaceOrCommentNode(x));

for (let i = 0; i < rewriteArguments.length; i++) {
const x = rewriteArguments[i];

if (isTokenNode(x) && isTokenString(x.value)) {
const original = x.value[4].value.trim();
const modified = rewriter({ url: original }, context);
const urlModifiers = rewriteArguments.slice(i + 1);

const modified = rewriter({ url: original, urlModifiers: urlModifiers.map((y) => y.toString()) }, context);
if (modified === false) {
return;
}

if (modified.url === original) {
break;
}

x.value[4].value = modified.url;
x.value[1] = `"${serializeString(modified.url)}"`;
const modifiedArguments = parseListOfComponentValues(
tokenize({
css: [
`"${serializeString(modified.url)}"`,
...(modified.urlModifiers ?? urlModifiers)
].join(' ')
})
);

componentValue.value = modifiedArguments;
componentValue.name[1] = 'url(';
componentValue.name[4].value = 'url';

return componentValue;
}

return;
}
},
);
Expand Down
27 changes: 27 additions & 0 deletions plugins/postcss-rewrite-url/test/_tape.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,33 @@ postcssTape(plugin)({
return false;
}

if (value.urlModifiers.includes('--url-modifier-a')) {
return {
url: value.url + '#modified-a',
};
}

if (value.urlModifiers.includes('--url-modifier-b')) {
return {
url: value.url + '#modified-b',
urlModifiers: [],
};
}

if (value.urlModifiers.includes('--url-modifier-c')) {
return {
url: value.url + '#modified-c',
urlModifiers: ['crossorigin(anonymous)'],
};
}

if (value.urlModifiers.includes('--url-modifier-d')) {
return {
url: value.url + '#modified-d',
urlModifiers: value.urlModifiers.filter((x) => x !== '--url-modifier-d'),
};
}

return {
url: value.url + '#modified',
};
Expand Down
24 changes: 24 additions & 0 deletions plugins/postcss-rewrite-url/test/basic.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,28 @@
background: url('foo.png');
}

.ignore {
background: rewrite-url(oops 'foo.png');
}

@foo rewrite-url('foo.png');

.url-modifiers {
/* No overrides, url modifiers should be preserved */
background: rewrite-url('foo.png' --url-modifier-a crossorigin(use-credentials) referrerpolicy(no-referrer));
}

.url-modifiers {
/* Empty list, all url modifiers should be removed */
background: rewrite-url('foo.png' --url-modifier-b crossorigin(use-credentials) referrerpolicy(no-referrer));
}

.url-modifiers {
/* A single explicit item, only that item should be present */
background: rewrite-url('foo.png' --url-modifier-c crossorigin(use-credentials) referrerpolicy(no-referrer));
}

.url-modifiers {
/* A filter on the original values, only a single item should be removed */
background: rewrite-url('foo.png' --url-modifier-d crossorigin(use-credentials) referrerpolicy(no-referrer));
}
24 changes: 24 additions & 0 deletions plugins/postcss-rewrite-url/test/basic.expect.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,28 @@
background: url('foo.png');
}

.ignore {
background: rewrite-url(oops 'foo.png');
}

@foo url("foo.png#modified");

.url-modifiers {
/* No overrides, url modifiers should be preserved */
background: url("foo.png#modified-a" --url-modifier-a crossorigin(use-credentials) referrerpolicy(no-referrer));
}

.url-modifiers {
/* Empty list, all url modifiers should be removed */
background: url("foo.png#modified-b");
}

.url-modifiers {
/* A single explicit item, only that item should be present */
background: url("foo.png#modified-c" crossorigin(anonymous));
}

.url-modifiers {
/* A filter on the original values, only a single item should be removed */
background: url("foo.png#modified-d" crossorigin(use-credentials) referrerpolicy(no-referrer));
}

0 comments on commit e3f064c

Please sign in to comment.