From e3f064cbb208070e518eb037bc71cc32ba7690c6 Mon Sep 17 00:00:00 2001 From: Romain Menke <11521496+romainmenke@users.noreply.github.com> Date: Sat, 30 Nov 2024 12:06:37 +0100 Subject: [PATCH] postcss-rewrite-url: url modifiers and docs (#1526) --- plugins/postcss-rewrite-url/CHANGELOG.md | 5 ++ plugins/postcss-rewrite-url/README.md | 63 +++++++++++++++++-- plugins/postcss-rewrite-url/dist/index.cjs | 2 +- plugins/postcss-rewrite-url/dist/index.d.ts | 1 + plugins/postcss-rewrite-url/dist/index.mjs | 2 +- plugins/postcss-rewrite-url/docs/README.md | 63 +++++++++++++++++-- plugins/postcss-rewrite-url/src/index.ts | 34 ++++++---- plugins/postcss-rewrite-url/test/_tape.mjs | 27 ++++++++ plugins/postcss-rewrite-url/test/basic.css | 24 +++++++ .../postcss-rewrite-url/test/basic.expect.css | 24 +++++++ 10 files changed, 218 insertions(+), 27 deletions(-) diff --git a/plugins/postcss-rewrite-url/CHANGELOG.md b/plugins/postcss-rewrite-url/CHANGELOG.md index 5f46821f3..1d37798e3 100644 --- a/plugins/postcss-rewrite-url/CHANGELOG.md +++ b/plugins/postcss-rewrite-url/CHANGELOG.md @@ -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_ diff --git a/plugins/postcss-rewrite-url/README.md b/plugins/postcss-rewrite-url/README.md index cc8a1c12f..b7b37b6ac 100644 --- a/plugins/postcss-rewrite-url/README.md +++ b/plugins/postcss-rewrite-url/README.md @@ -71,11 +71,12 @@ instructions for: Determine how urls are rewritten with the `rewriter` callback. ```ts -export interface ValueToRewrite { - url: string +interface ValueToRewrite { + url: string; + urlModifiers: Array; } -export interface RewriteContext { +interface RewriteContext { type: 'declaration-value' | 'at-rule-prelude'; from: string | undefined; rootFrom: string | undefined; @@ -83,10 +84,10 @@ export interface RewriteContext { 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; }; ``` @@ -94,12 +95,22 @@ export type pluginOptions = { ```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', }; @@ -107,6 +118,46 @@ postcssRewriteURL({ }) ``` +## 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( * ) +``` + +#### [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( * )" + } + }, + ], + '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 diff --git a/plugins/postcss-rewrite-url/dist/index.cjs b/plugins/postcss-rewrite-url/dist/index.cjs index 2c299cbea..0da339d4e 100644 --- a/plugins/postcss-rewrite-url/dist/index.cjs +++ b/plugins/postcss-rewrite-url/dist/index.cjs @@ -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;oe.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; diff --git a/plugins/postcss-rewrite-url/dist/index.d.ts b/plugins/postcss-rewrite-url/dist/index.d.ts index b19174133..0335102c3 100644 --- a/plugins/postcss-rewrite-url/dist/index.d.ts +++ b/plugins/postcss-rewrite-url/dist/index.d.ts @@ -20,6 +20,7 @@ export declare type Rewriter = (value: ValueToRewrite, context: RewriteContext) export declare interface ValueToRewrite { url: string; + urlModifiers: Array; } export { } diff --git a/plugins/postcss-rewrite-url/dist/index.mjs b/plugins/postcss-rewrite-url/dist/index.mjs index a24332650..9944a30a7 100644 --- a/plugins/postcss-rewrite-url/dist/index.mjs +++ b/plugins/postcss-rewrite-url/dist/index.mjs @@ -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;sr.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}; diff --git a/plugins/postcss-rewrite-url/docs/README.md b/plugins/postcss-rewrite-url/docs/README.md index da2c16fa3..63f61c8be 100644 --- a/plugins/postcss-rewrite-url/docs/README.md +++ b/plugins/postcss-rewrite-url/docs/README.md @@ -38,11 +38,12 @@ Determine how urls are rewritten with the `rewriter` callback. ```ts -export interface ValueToRewrite { - url: string +interface ValueToRewrite { + url: string; + urlModifiers: Array; } -export interface RewriteContext { +interface RewriteContext { type: 'declaration-value' | 'at-rule-prelude'; from: string | undefined; rootFrom: string | undefined; @@ -50,10 +51,10 @@ export interface RewriteContext { 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; }; ``` @@ -61,12 +62,22 @@ export type pluginOptions = { ```js ({ 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', }; @@ -74,4 +85,44 @@ export type pluginOptions = { }) ``` +## Syntax + +[] 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( * ) +``` + +#### [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( * )" + } + }, + ], + 'function-no-unknown': [ + true, + { + "ignoreFunctions": ["rewrite-url"] + } + ], +``` + diff --git a/plugins/postcss-rewrite-url/src/index.ts b/plugins/postcss-rewrite-url/src/index.ts index 7954364a0..521eb2b34 100644 --- a/plugins/postcss-rewrite-url/src/index.ts +++ b/plugins/postcss-rewrite-url/src/index.ts @@ -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; } export interface RewriteContext { @@ -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; } }, ); diff --git a/plugins/postcss-rewrite-url/test/_tape.mjs b/plugins/postcss-rewrite-url/test/_tape.mjs index b9a882ea3..9cd346dd2 100644 --- a/plugins/postcss-rewrite-url/test/_tape.mjs +++ b/plugins/postcss-rewrite-url/test/_tape.mjs @@ -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', }; diff --git a/plugins/postcss-rewrite-url/test/basic.css b/plugins/postcss-rewrite-url/test/basic.css index 59855650c..eadf2f4ed 100644 --- a/plugins/postcss-rewrite-url/test/basic.css +++ b/plugins/postcss-rewrite-url/test/basic.css @@ -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)); +} diff --git a/plugins/postcss-rewrite-url/test/basic.expect.css b/plugins/postcss-rewrite-url/test/basic.expect.css index e01949a14..43ca57f8f 100644 --- a/plugins/postcss-rewrite-url/test/basic.expect.css +++ b/plugins/postcss-rewrite-url/test/basic.expect.css @@ -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)); +}