From a8ea7630e4d14509f5698b571314263bd20c3f74 Mon Sep 17 00:00:00 2001 From: Romain Menke <11521496+romainmenke@users.noreply.github.com> Date: Mon, 21 Aug 2023 22:27:19 +0200 Subject: [PATCH] `postcss-rebase-url` (#1082) --- .github/ISSUE_TEMPLATE/css-issue.yml | 1 + .github/ISSUE_TEMPLATE/plugin-issue.yml | 1 + .github/labeler.yml | 4 + package-lock.json | 32 +++ plugins/postcss-rebase-url/.gitignore | 6 + plugins/postcss-rebase-url/.nvmrc | 1 + plugins/postcss-rebase-url/.tape.mjs | 22 ++ plugins/postcss-rebase-url/CHANGELOG.md | 11 + plugins/postcss-rebase-url/INSTALL.md | 235 ++++++++++++++++++ plugins/postcss-rebase-url/LICENSE.md | 18 ++ plugins/postcss-rebase-url/README.md | 70 ++++++ plugins/postcss-rebase-url/dist/index.cjs | 1 + plugins/postcss-rebase-url/dist/index.d.ts | 5 + plugins/postcss-rebase-url/dist/index.mjs | 1 + .../dist/normalized-dir.d.ts | 7 + .../dist/serialize-string.d.ts | 9 + plugins/postcss-rebase-url/docs/README.md | 48 ++++ plugins/postcss-rebase-url/package.json | 85 +++++++ plugins/postcss-rebase-url/src/index.ts | 103 ++++++++ .../postcss-rebase-url/src/normalized-dir.ts | 18 ++ plugins/postcss-rebase-url/src/rebase.d.ts | 1 + plugins/postcss-rebase-url/src/rebase.mjs | 42 ++++ .../src/serialize-string.ts | 42 ++++ plugins/postcss-rebase-url/test/_import.mjs | 6 + plugins/postcss-rebase-url/test/_require.cjs | 6 + plugins/postcss-rebase-url/test/basic.css | 9 + .../postcss-rebase-url/test/basic.expect.css | 44 ++++ .../test/examples/example.css | 1 + .../test/examples/example.expect.css | 3 + .../test/examples/imports/basic.css | 3 + .../postcss-rebase-url/test/images/green.png | Bin 0 -> 107 bytes .../postcss-rebase-url/test/imports/basic.css | 36 +++ .../postcss-rebase-url/test/imports/green.png | Bin 0 -> 107 bytes .../postcss-rebase-url/test/unit/basic.mjs | 92 +++++++ .../postcss-rebase-url/test/unit/ignored.mjs | 60 +++++ .../postcss-rebase-url/test/unit/index.mjs | 3 + .../test/unit/unexpected-urls.mjs | 90 +++++++ plugins/postcss-rebase-url/tsconfig.json | 10 + 38 files changed, 1126 insertions(+) create mode 100644 plugins/postcss-rebase-url/.gitignore create mode 100644 plugins/postcss-rebase-url/.nvmrc create mode 100644 plugins/postcss-rebase-url/.tape.mjs create mode 100644 plugins/postcss-rebase-url/CHANGELOG.md create mode 100644 plugins/postcss-rebase-url/INSTALL.md create mode 100644 plugins/postcss-rebase-url/LICENSE.md create mode 100644 plugins/postcss-rebase-url/README.md create mode 100644 plugins/postcss-rebase-url/dist/index.cjs create mode 100644 plugins/postcss-rebase-url/dist/index.d.ts create mode 100644 plugins/postcss-rebase-url/dist/index.mjs create mode 100644 plugins/postcss-rebase-url/dist/normalized-dir.d.ts create mode 100644 plugins/postcss-rebase-url/dist/serialize-string.d.ts create mode 100644 plugins/postcss-rebase-url/docs/README.md create mode 100644 plugins/postcss-rebase-url/package.json create mode 100644 plugins/postcss-rebase-url/src/index.ts create mode 100644 plugins/postcss-rebase-url/src/normalized-dir.ts create mode 100644 plugins/postcss-rebase-url/src/rebase.d.ts create mode 100644 plugins/postcss-rebase-url/src/rebase.mjs create mode 100644 plugins/postcss-rebase-url/src/serialize-string.ts create mode 100644 plugins/postcss-rebase-url/test/_import.mjs create mode 100644 plugins/postcss-rebase-url/test/_require.cjs create mode 100644 plugins/postcss-rebase-url/test/basic.css create mode 100644 plugins/postcss-rebase-url/test/basic.expect.css create mode 100644 plugins/postcss-rebase-url/test/examples/example.css create mode 100644 plugins/postcss-rebase-url/test/examples/example.expect.css create mode 100644 plugins/postcss-rebase-url/test/examples/imports/basic.css create mode 100644 plugins/postcss-rebase-url/test/images/green.png create mode 100644 plugins/postcss-rebase-url/test/imports/basic.css create mode 100644 plugins/postcss-rebase-url/test/imports/green.png create mode 100644 plugins/postcss-rebase-url/test/unit/basic.mjs create mode 100644 plugins/postcss-rebase-url/test/unit/ignored.mjs create mode 100644 plugins/postcss-rebase-url/test/unit/index.mjs create mode 100644 plugins/postcss-rebase-url/test/unit/unexpected-urls.mjs create mode 100644 plugins/postcss-rebase-url/tsconfig.json diff --git a/.github/ISSUE_TEMPLATE/css-issue.yml b/.github/ISSUE_TEMPLATE/css-issue.yml index 73261a12a..c1670cf14 100644 --- a/.github/ISSUE_TEMPLATE/css-issue.yml +++ b/.github/ISSUE_TEMPLATE/css-issue.yml @@ -104,6 +104,7 @@ body: - PostCSS Place - PostCSS Progressive Custom Properties - PostCSS Pseudo Class Any Link + - PostCSS Rebase URL - PostCSS Rebeccapurple - PostCSS Relative Color Syntax - PostCSS Replace Overflow Wrap diff --git a/.github/ISSUE_TEMPLATE/plugin-issue.yml b/.github/ISSUE_TEMPLATE/plugin-issue.yml index 32c11380d..10d29080b 100644 --- a/.github/ISSUE_TEMPLATE/plugin-issue.yml +++ b/.github/ISSUE_TEMPLATE/plugin-issue.yml @@ -106,6 +106,7 @@ body: - PostCSS Place - PostCSS Progressive Custom Properties - PostCSS Pseudo Class Any Link + - PostCSS Rebase URL - PostCSS Rebeccapurple - PostCSS Relative Color Syntax - PostCSS Replace Overflow Wrap diff --git a/.github/labeler.yml b/.github/labeler.yml index 4fe061eae..299ecd54b 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -226,6 +226,10 @@ - plugins/postcss-pseudo-class-any-link/** - experimental/postcss-pseudo-class-any-link/** +"plugins/postcss-rebase-url": + - plugins/postcss-rebase-url/** + - experimental/postcss-rebase-url/** + "plugins/postcss-relative-color-syntax": - plugins/postcss-relative-color-syntax/** - experimental/postcss-relative-color-syntax/** diff --git a/package-lock.json b/package-lock.json index bc2ff4823..0cace916a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2161,6 +2161,10 @@ "resolved": "plugins/postcss-progressive-custom-properties", "link": true }, + "node_modules/@csstools/postcss-rebase-url": { + "resolved": "plugins/postcss-rebase-url", + "link": true + }, "node_modules/@csstools/postcss-relative-color-syntax": { "resolved": "plugins/postcss-relative-color-syntax", "link": true @@ -14027,6 +14031,34 @@ "postcss": "^8.4" } }, + "plugins/postcss-rebase-url": { + "version": "0.1.0-alpha.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^2.3.0", + "@csstools/css-tokenizer": "^2.1.1" + }, + "devDependencies": { + "@csstools/postcss-tape": "*", + "postcss-import": "^15.1.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, "plugins/postcss-relative-color-syntax": { "name": "@csstools/postcss-relative-color-syntax", "version": "2.0.1", diff --git a/plugins/postcss-rebase-url/.gitignore b/plugins/postcss-rebase-url/.gitignore new file mode 100644 index 000000000..e5b28db4a --- /dev/null +++ b/plugins/postcss-rebase-url/.gitignore @@ -0,0 +1,6 @@ +node_modules +package-lock.json +yarn.lock +*.result.css +*.result.css.map +*.result.html diff --git a/plugins/postcss-rebase-url/.nvmrc b/plugins/postcss-rebase-url/.nvmrc new file mode 100644 index 000000000..6ed5da955 --- /dev/null +++ b/plugins/postcss-rebase-url/.nvmrc @@ -0,0 +1 @@ +v20.2.0 diff --git a/plugins/postcss-rebase-url/.tape.mjs b/plugins/postcss-rebase-url/.tape.mjs new file mode 100644 index 000000000..f3635dc8e --- /dev/null +++ b/plugins/postcss-rebase-url/.tape.mjs @@ -0,0 +1,22 @@ +import { postcssTape } from '@csstools/postcss-tape'; +import plugin from '@csstools/postcss-rebase-url'; +import postcssImport from 'postcss-import'; + +import './test/unit/index.mjs'; + +await postcssTape(plugin)({ + basic: { + message: "supports basic usage", + plugins: [ + postcssImport(), + plugin() + ] + }, + 'examples/example': { + message: 'minimal example', + plugins: [ + postcssImport(), + plugin() + ] + } +}); diff --git a/plugins/postcss-rebase-url/CHANGELOG.md b/plugins/postcss-rebase-url/CHANGELOG.md new file mode 100644 index 000000000..f78e4fe20 --- /dev/null +++ b/plugins/postcss-rebase-url/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changes to PostCSS Rebase URL + +### Unreleased (major) + +- Initial major version + +### 0.1.0-alpha.0 + +_August 14, 2023_ + +- Initial version diff --git a/plugins/postcss-rebase-url/INSTALL.md b/plugins/postcss-rebase-url/INSTALL.md new file mode 100644 index 000000000..7d0cdb056 --- /dev/null +++ b/plugins/postcss-rebase-url/INSTALL.md @@ -0,0 +1,235 @@ +# Installing PostCSS Rebase URL + +[PostCSS Rebase URL] runs in all Node environments, with special instructions for: + +- [Node](#node) +- [PostCSS CLI](#postcss-cli) +- [PostCSS Load Config](#postcss-load-config) +- [Webpack](#webpack) +- [Next.js](#nextjs) +- [Gulp](#gulp) +- [Grunt](#grunt) + + + +## Node + +Add [PostCSS Rebase URL] to your project: + +```bash +npm install postcss @csstools/postcss-rebase-url --save-dev +``` + +Use it as a [PostCSS] plugin: + +```js +// commonjs +const postcss = require('postcss'); +const postcssRebaseURL = require('@csstools/postcss-rebase-url'); + +postcss([ + postcssRebaseURL(/* pluginOptions */) +]).process(YOUR_CSS /*, processOptions */); +``` + +```js +// esm +import postcss from 'postcss'; +import postcssRebaseURL from '@csstools/postcss-rebase-url'; + +postcss([ + postcssRebaseURL(/* pluginOptions */) +]).process(YOUR_CSS /*, processOptions */); +``` + +## PostCSS CLI + +Add [PostCSS CLI] to your project: + +```bash +npm install postcss-cli @csstools/postcss-rebase-url --save-dev +``` + +Use [PostCSS Rebase URL] in your `postcss.config.js` configuration file: + +```js +const postcssRebaseURL = require('@csstools/postcss-rebase-url'); + +module.exports = { + plugins: [ + postcssRebaseURL(/* pluginOptions */) + ] +} +``` + +## PostCSS Load Config + +If your framework/CLI supports [`postcss-load-config`](https://github.com/postcss/postcss-load-config). + +```bash +npm install @csstools/postcss-rebase-url --save-dev +``` + +`package.json`: + +```json +{ + "postcss": { + "plugins": { + "@csstools/postcss-rebase-url": {} + } + } +} +``` + +`.postcssrc.json`: + +```json +{ + "plugins": { + "@csstools/postcss-rebase-url": {} + } +} +``` + +_See the [README of `postcss-load-config`](https://github.com/postcss/postcss-load-config#usage) for more usage options._ + +## Webpack + +_Webpack version 5_ + +Add [PostCSS Loader] to your project: + +```bash +npm install postcss-loader @csstools/postcss-rebase-url --save-dev +``` + +Use [PostCSS Rebase URL] in your Webpack configuration: + +```js +module.exports = { + module: { + rules: [ + { + test: /\.css$/i, + use: [ + "style-loader", + { + loader: "css-loader", + options: { importLoaders: 1 }, + }, + { + loader: "postcss-loader", + options: { + postcssOptions: { + plugins: [ + // Other plugins, + [ + "@csstools/postcss-rebase-url", + { + // Options + }, + ], + ], + }, + }, + }, + ], + }, + ], + }, +}; +``` + +## Next.js + +Read the instructions on how to [customize the PostCSS configuration in Next.js](https://nextjs.org/docs/advanced-features/customizing-postcss-config) + +```bash +npm install @csstools/postcss-rebase-url --save-dev +``` + +Use [PostCSS Rebase URL] in your `postcss.config.json` file: + +```json +{ + "plugins": [ + "@csstools/postcss-rebase-url" + ] +} +``` + +```json5 +{ + "plugins": [ + [ + "@csstools/postcss-rebase-url", + { + // Optionally add plugin options + } + ] + ] +} +``` + +## Gulp + +Add [Gulp PostCSS] to your project: + +```bash +npm install gulp-postcss @csstools/postcss-rebase-url --save-dev +``` + +Use [PostCSS Rebase URL] in your Gulpfile: + +```js +const postcss = require('gulp-postcss'); +const postcssRebaseURL = require('@csstools/postcss-rebase-url'); + +gulp.task('css', function () { + var plugins = [ + postcssRebaseURL(/* pluginOptions */) + ]; + + return gulp.src('./src/*.css') + .pipe(postcss(plugins)) + .pipe(gulp.dest('.')); +}); +``` + +## Grunt + +Add [Grunt PostCSS] to your project: + +```bash +npm install grunt-postcss @csstools/postcss-rebase-url --save-dev +``` + +Use [PostCSS Rebase URL] in your Gruntfile: + +```js +const postcssRebaseURL = require('@csstools/postcss-rebase-url'); + +grunt.loadNpmTasks('grunt-postcss'); + +grunt.initConfig({ + postcss: { + options: { + processors: [ + postcssRebaseURL(/* pluginOptions */) + ] + }, + dist: { + src: '*.css' + } + } +}); +``` + +[Gulp PostCSS]: https://github.com/postcss/gulp-postcss +[Grunt PostCSS]: https://github.com/nDmitry/grunt-postcss +[PostCSS]: https://github.com/postcss/postcss +[PostCSS CLI]: https://github.com/postcss/postcss-cli +[PostCSS Loader]: https://github.com/postcss/postcss-loader +[PostCSS Rebase URL]: https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-rebase-url +[Next.js]: https://nextjs.org diff --git a/plugins/postcss-rebase-url/LICENSE.md b/plugins/postcss-rebase-url/LICENSE.md new file mode 100644 index 000000000..e8ae93b9f --- /dev/null +++ b/plugins/postcss-rebase-url/LICENSE.md @@ -0,0 +1,18 @@ +MIT No Attribution (MIT-0) + +Copyright © CSSTools Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/postcss-rebase-url/README.md b/plugins/postcss-rebase-url/README.md new file mode 100644 index 000000000..3b40ecaad --- /dev/null +++ b/plugins/postcss-rebase-url/README.md @@ -0,0 +1,70 @@ +# PostCSS Rebase URL [PostCSS Logo][PostCSS] + +[npm version][npm-url] [Build Status][cli-url] [Discord][discord] + +[PostCSS Rebase URL] rebases `url()` functions when transforming CSS. + +When bundling CSS, the location of the final stylesheet file will be different than the individual source files. +[PostCSS Rebase URL] rewrites the contents of `url()` functions so that relative paths continue to work. + +Instead of manually mapping where the files will be in the final output you can use this plugin +and simply use the relative paths to each source file. + +_If you need something with more knobs and dials, please checkout [`postcss-url`](https://www.npmjs.com/package/postcss-url)_ + +```pcss +/* when used with a bundler like `postcss-import` */ + +/* test/examples/example.css */ +@import url("imports/basic.css"); + +/* test/examples/imports/basic.css */ +.foo { + background: url('../../images/green.png'); +} + +/* becomes */ + +/* test/examples/example.expect.css */ +.foo { + background: url("../images/green.png"); +} +``` + +## Usage + +Add [PostCSS Rebase URL] to your project: + +```bash +npm install postcss @csstools/postcss-rebase-url --save-dev +``` + +Use it as a [PostCSS] plugin: + +```js +const postcss = require('postcss'); +const postcssRebaseURL = require('@csstools/postcss-rebase-url'); + +postcss([ + postcssRebaseURL(/* pluginOptions */) +]).process(YOUR_CSS /*, processOptions */); +``` + +[PostCSS Rebase URL] runs in all Node environments, with special +instructions for: + +- [Node](INSTALL.md#node) +- [PostCSS CLI](INSTALL.md#postcss-cli) +- [PostCSS Load Config](INSTALL.md#postcss-load-config) +- [Webpack](INSTALL.md#webpack) +- [Next.js](INSTALL.md#nextjs) +- [Gulp](INSTALL.md#gulp) +- [Grunt](INSTALL.md#grunt) + +[cli-url]: https://github.com/csstools/postcss-plugins/actions/workflows/test.yml?query=workflow/test + +[discord]: https://discord.gg/bUadyRwkJS +[npm-url]: https://www.npmjs.com/package/@csstools/postcss-rebase-url + +[PostCSS]: https://github.com/postcss/postcss +[PostCSS Rebase URL]: https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-rebase-url diff --git a/plugins/postcss-rebase-url/dist/index.cjs b/plugins/postcss-rebase-url/dist/index.cjs new file mode 100644 index 000000000..ac5172bb0 --- /dev/null +++ b/plugins/postcss-rebase-url/dist/index.cjs @@ -0,0 +1 @@ +"use strict";var e=require("@csstools/css-tokenizer"),r=require("@csstools/css-parser-algorithms"),t=require("path");const s=/^([a-z0-9.+-_]+:)?\/\//i;function rebase(e,r,i){if(e.startsWith("data:"))return!1;if(s.test(e))return!1;if(e.startsWith("/"))return e;if(e.startsWith("#"))return e;try{const r=new URL(e);if(r.port||r.protocol)return!1}catch{}const o=t.posix.resolve(t.posix.join(r,e));return t.posix.relative(i,o)}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}function normalizedDir(e){return t.parse(t.resolve(e.trim())).dir.split(t.sep).join(t.posix.sep)}const i=/url\(/i,o=/url/i,creator=()=>({postcssPlugin:"postcss-rebase-url",prepare(){const t=new WeakSet;return{Declaration(s,{result:n}){var a;if(t.has(s))return;const{from:u}=n.opts;if(!u)return;if(null==(a=s.source)||!a.input.from)return;if(!i.test(s.value))return;const l=normalizedDir(u),c=s.source.input.from.trim();if(!c)return;const f=normalizedDir(c),p=r.parseCommaSeparatedListOfComponentValues(e.tokenize({css:s.value})),v=r.replaceComponentValues(p,(t=>{if(r.isTokenNode(t)&&t.value[0]===e.TokenType.URL){const e=rebase(t.value[4].value.trim(),f,l);if(e)return t.value[4].value=e,t.value[1]=`url(${serializeString(e)})`,t}if(r.isFunctionNode(t)&&o.test(t.getName()))for(const s of t.value)if(!r.isWhitespaceNode(s)&&!r.isCommentNode(s)&&r.isTokenNode(s)&&s.value[0]===e.TokenType.String){const e=rebase(s.value[4].value.trim(),f,l);if(e)return s.value[4].value=e,s.value[1]=`"${serializeString(e)}"`,t;break}})),m=r.stringify(v);m!==s.value&&(s.value=m,t.add(s))}}}});creator.postcss=!0,module.exports=creator; diff --git a/plugins/postcss-rebase-url/dist/index.d.ts b/plugins/postcss-rebase-url/dist/index.d.ts new file mode 100644 index 000000000..877e03130 --- /dev/null +++ b/plugins/postcss-rebase-url/dist/index.d.ts @@ -0,0 +1,5 @@ +import type { PluginCreator } from 'postcss'; +/** postcss-rebase-url plugin options */ +export type pluginOptions = never; +declare const creator: PluginCreator; +export default creator; diff --git a/plugins/postcss-rebase-url/dist/index.mjs b/plugins/postcss-rebase-url/dist/index.mjs new file mode 100644 index 000000000..75c734492 --- /dev/null +++ b/plugins/postcss-rebase-url/dist/index.mjs @@ -0,0 +1 @@ +import{tokenize as r,TokenType as e}from"@csstools/css-tokenizer";import{parseCommaSeparatedListOfComponentValues as t,replaceComponentValues as s,isTokenNode as i,isFunctionNode as o,isWhitespaceNode as a,isCommentNode as n,stringify as u}from"@csstools/css-parser-algorithms";import l from"path";const c=/^([a-z0-9.+-_]+:)?\/\//i;function rebase(r,e,t){if(r.startsWith("data:"))return!1;if(c.test(r))return!1;if(r.startsWith("/"))return r;if(r.startsWith("#"))return r;try{const e=new URL(r);if(e.port||e.protocol)return!1}catch{}const s=l.posix.resolve(l.posix.join(e,r));return l.posix.relative(t,s)}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}function normalizedDir(r){return l.parse(l.resolve(r.trim())).dir.split(l.sep).join(l.posix.sep)}const f=/url\(/i,p=/url/i,creator=()=>({postcssPlugin:"postcss-rebase-url",prepare(){const l=new WeakSet;return{Declaration(c,{result:v}){var m;if(l.has(c))return;const{from:d}=v.opts;if(!d)return;if(null==(m=c.source)||!m.input.from)return;if(!f.test(c.value))return;const g=normalizedDir(d),b=c.source.input.from.trim();if(!b)return;const S=normalizedDir(b),h=t(r({css:c.value})),z=s(h,(r=>{if(i(r)&&r.value[0]===e.URL){const e=rebase(r.value[4].value.trim(),S,g);if(e)return r.value[4].value=e,r.value[1]=`url(${serializeString(e)})`,r}if(o(r)&&p.test(r.getName()))for(const t of r.value)if(!a(t)&&!n(t)&&i(t)&&t.value[0]===e.String){const e=rebase(t.value[4].value.trim(),S,g);if(e)return t.value[4].value=e,t.value[1]=`"${serializeString(e)}"`,r;break}})),k=u(z);k!==c.value&&(c.value=k,l.add(c))}}}});creator.postcss=!0;export{creator as default}; diff --git a/plugins/postcss-rebase-url/dist/normalized-dir.d.ts b/plugins/postcss-rebase-url/dist/normalized-dir.d.ts new file mode 100644 index 000000000..70ed1c972 --- /dev/null +++ b/plugins/postcss-rebase-url/dist/normalized-dir.d.ts @@ -0,0 +1,7 @@ +/** + * Returns a posix path for the directory of the given file path. + * + * @param {string} x The file path to normalize. + * @returns {string} The normalized directory path. + */ +export declare function normalizedDir(x: string): string; diff --git a/plugins/postcss-rebase-url/dist/serialize-string.d.ts b/plugins/postcss-rebase-url/dist/serialize-string.d.ts new file mode 100644 index 000000000..ce83d6ae2 --- /dev/null +++ b/plugins/postcss-rebase-url/dist/serialize-string.d.ts @@ -0,0 +1,9 @@ +/** + * Serialize a string as a quoted CSS string. + * + * @param {string} str The contents for the string value. + * @returns {string} The quoted CSS string. + * + * @see https://www.w3.org/TR/cssom-1/#common-serializing-idioms + */ +export declare function serializeString(str: string): string; diff --git a/plugins/postcss-rebase-url/docs/README.md b/plugins/postcss-rebase-url/docs/README.md new file mode 100644 index 000000000..96d0ffcf2 --- /dev/null +++ b/plugins/postcss-rebase-url/docs/README.md @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + +
+ +[] rebases `url()` functions when transforming CSS. + +When bundling CSS, the location of the final stylesheet file will be different than the individual source files. +[] rewrites the contents of `url()` functions so that relative paths continue to work. + +Instead of manually mapping where the files will be in the final output you can use this plugin +and simply use the relative paths to each source file. + +_If you need something with more knobs and dials, please checkout [`postcss-url`](https://www.npmjs.com/package/postcss-url)_ + +```pcss +/* when used with a bundler like `postcss-import` */ + +/* test/examples/example.css */ + + +/* test/examples/imports/basic.css */ + + +/* becomes */ + +/* test/examples/example.expect.css */ + +``` + + + + + + diff --git a/plugins/postcss-rebase-url/package.json b/plugins/postcss-rebase-url/package.json new file mode 100644 index 000000000..a9f6714bb --- /dev/null +++ b/plugins/postcss-rebase-url/package.json @@ -0,0 +1,85 @@ +{ + "name": "@csstools/postcss-rebase-url", + "description": "Rebase url() functions when transforming CSS", + "version": "0.1.0-alpha.0", + "contributors": [ + { + "name": "Antonio Laguna", + "email": "antonio@laguna.es", + "url": "https://antonio.laguna.es" + }, + { + "name": "Romain Menke", + "email": "romainmenke@gmail.com" + } + ], + "license": "MIT-0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "default": "./dist/index.mjs" + } + }, + "files": [ + "CHANGELOG.md", + "LICENSE.md", + "README.md", + "dist" + ], + "dependencies": { + "@csstools/css-parser-algorithms": "^2.3.0", + "@csstools/css-tokenizer": "^2.1.1" + }, + "peerDependencies": { + "postcss": "^8.4" + }, + "devDependencies": { + "@csstools/postcss-tape": "*", + "postcss-import": "^15.1.0" + }, + "scripts": { + "build": "rollup -c ../../rollup/default.mjs", + "docs": "node ../../.github/bin/generate-docs/install.mjs && node ../../.github/bin/generate-docs/readme.mjs", + "lint": "node ../../.github/bin/format-package-json.mjs", + "prepublishOnly": "npm run build && npm run test", + "test": "node .tape.mjs && node ./test/_import.mjs && node ./test/_require.cjs", + "test:rewrite-expects": "REWRITE_EXPECTS=true node .tape.mjs" + }, + "homepage": "https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-rebase-url#readme", + "repository": { + "type": "git", + "url": "https://github.com/csstools/postcss-plugins.git", + "directory": "plugins/postcss-rebase-url" + }, + "bugs": "https://github.com/csstools/postcss-plugins/issues", + "keywords": [ + "postcss-plugin", + "rebase", + "url" + ], + "csstools": { + "exportName": "postcssRebaseURL", + "humanReadableName": "PostCSS Rebase URL" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/plugins/postcss-rebase-url/src/index.ts b/plugins/postcss-rebase-url/src/index.ts new file mode 100644 index 000000000..796adacfc --- /dev/null +++ b/plugins/postcss-rebase-url/src/index.ts @@ -0,0 +1,103 @@ +import type { PluginCreator } from 'postcss'; +import { TokenType, tokenize } from '@csstools/css-tokenizer'; +import { isCommentNode, isFunctionNode, isTokenNode, isWhitespaceNode, parseCommaSeparatedListOfComponentValues, replaceComponentValues, stringify } from '@csstools/css-parser-algorithms'; +import { rebase } from './rebase'; +import { serializeString } from './serialize-string'; +import { normalizedDir } from './normalized-dir'; + +/** postcss-rebase-url plugin options */ +export type pluginOptions = never; + +const URL_FUNCTION_CALL = /url\(/i; +const URL_FUNCTION_NAME = /url/i; + +const creator: PluginCreator = () => { + return { + postcssPlugin: 'postcss-rebase-url', + prepare() { + const visited = new WeakSet(); + + return { + Declaration(decl, { result }) { + if (visited.has(decl)) { + return; + } + + const { from: fromEntryPoint } = result.opts; + if (!fromEntryPoint) { + return; + } + + if (!decl.source?.input.from) { + return; + } + + if (!URL_FUNCTION_CALL.test(decl.value)) { + return; + } + + const fromEntryPointDir = normalizedDir(fromEntryPoint); + + const from = decl.source.input.from.trim(); + if (!from) { + return; + } + + const fromDir = normalizedDir(from); + + const componentValuesList = parseCommaSeparatedListOfComponentValues(tokenize({ css: decl.value })); + const modifiedComponentValuesList = replaceComponentValues( + componentValuesList, + (componentValue) => { + if ( + isTokenNode(componentValue) && + componentValue.value[0] === TokenType.URL + ) { + const rebased = rebase(componentValue.value[4].value.trim(), fromDir, fromEntryPointDir); + if (rebased) { + componentValue.value[4].value = rebased; + + // Files with quotes + componentValue.value[1] = `url(${serializeString(rebased)})`; + return componentValue; + } + } + + if ( + isFunctionNode(componentValue) && + URL_FUNCTION_NAME.test(componentValue.getName()) + ) { + for (const x of componentValue.value) { + if (isWhitespaceNode(x) || isCommentNode(x)) { + continue; + } + + if (isTokenNode(x) && x.value[0] === TokenType.String) { + const rebased = rebase(x.value[4].value.trim(), fromDir, fromEntryPointDir); + if (rebased) { + x.value[4].value = rebased; + x.value[1] = `"${serializeString(rebased)}"`; + return componentValue; + } + + break; + } + } + } + }, + ); + + const modifiedValue = stringify(modifiedComponentValuesList); + if (modifiedValue !== decl.value) { + decl.value = modifiedValue; + visited.add(decl); + } + }, + }; + }, + }; +}; + +creator.postcss = true; + +export default creator; diff --git a/plugins/postcss-rebase-url/src/normalized-dir.ts b/plugins/postcss-rebase-url/src/normalized-dir.ts new file mode 100644 index 000000000..746a58ae4 --- /dev/null +++ b/plugins/postcss-rebase-url/src/normalized-dir.ts @@ -0,0 +1,18 @@ +import path from 'path'; + +/** + * Returns a posix path for the directory of the given file path. + * + * @param {string} x The file path to normalize. + * @returns {string} The normalized directory path. + */ +export function normalizedDir(x: string): string { + // Resolve the path to eliminate any relative path components. + const dir = path.parse(path.resolve(x.trim())).dir; + // Split the path by the native path separator + const dirPathComponents = dir.split(path.sep); + // Join the path components with the posix path separator + const posixDir = dirPathComponents.join(path.posix.sep); + + return posixDir; +} diff --git a/plugins/postcss-rebase-url/src/rebase.d.ts b/plugins/postcss-rebase-url/src/rebase.d.ts new file mode 100644 index 000000000..1147e26ae --- /dev/null +++ b/plugins/postcss-rebase-url/src/rebase.d.ts @@ -0,0 +1 @@ +export function rebase(url: string, fromDir: string, fromEntryPointDir: string): string | false; diff --git a/plugins/postcss-rebase-url/src/rebase.mjs b/plugins/postcss-rebase-url/src/rebase.mjs new file mode 100644 index 000000000..d0ba02c76 --- /dev/null +++ b/plugins/postcss-rebase-url/src/rebase.mjs @@ -0,0 +1,42 @@ +import path from 'path'; + +const hasProtocol = /^([a-z0-9.+-_]+:)?\/\//i; + +/** + * Rebase a URL from one directory to another. + * + * @param {string} url The URL to rebase. + * @param {string} fromDir The directory to rebase from. + * @param {string} fromEntryPointDir The directory of the entry point. + * @returns {string|false} The rebased URL, or `false` if the URL is absolute. + */ +export function rebase(url, fromDir, fromEntryPointDir) { + if (url.startsWith('data:')) { + return false; + } + + if (hasProtocol.test(url)) { + return false; + } + + if (url.startsWith('/')) { + return url; + } + + if (url.startsWith('#')) { + return url; + } + + try { + const x = new URL(url); + if (x.port || x.protocol) { + return false; + } + } catch { } // eslint-disable-line no-empty + + const absPath = path.posix.resolve( + path.posix.join(fromDir, url), + ); + + return path.posix.relative(fromEntryPointDir, absPath); +} diff --git a/plugins/postcss-rebase-url/src/serialize-string.ts b/plugins/postcss-rebase-url/src/serialize-string.ts new file mode 100644 index 000000000..8cd06f279 --- /dev/null +++ b/plugins/postcss-rebase-url/src/serialize-string.ts @@ -0,0 +1,42 @@ +/** + * Serialize a string as a quoted CSS string. + * + * @param {string} str The contents for the string value. + * @returns {string} The quoted CSS string. + * + * @see https://www.w3.org/TR/cssom-1/#common-serializing-idioms + */ +export function serializeString(str: string): string { + let out = ''; + + for (const codePoint of str) { + const codePointNumber = codePoint.codePointAt(0); + if (typeof codePointNumber === 'undefined') { + out += String.fromCodePoint(0xFFFD); + continue; + } + + switch (codePointNumber) { + case 0x0000: + out += String.fromCodePoint(0xFFFD); + break; + case 0x007F: + out += `\\${codePointNumber.toString(16)}`; + break; + case 0x0022: + case 0x0027: + case 0x005C: + out += `\\${codePoint}`; + break; + default: + if (0x0001 <= codePointNumber && codePointNumber <= 0x001f) { + out += `\\${codePointNumber.toString(16)} `; + break; + } + + out += codePoint; + } + } + + return out; +} diff --git a/plugins/postcss-rebase-url/test/_import.mjs b/plugins/postcss-rebase-url/test/_import.mjs new file mode 100644 index 000000000..4b0bced28 --- /dev/null +++ b/plugins/postcss-rebase-url/test/_import.mjs @@ -0,0 +1,6 @@ +import assert from 'assert'; +import plugin from '@csstools/postcss-rebase-url'; +plugin(); + +assert.ok(plugin.postcss, 'should have "postcss flag"'); +assert.equal(typeof plugin, 'function', 'should return a function'); diff --git a/plugins/postcss-rebase-url/test/_require.cjs b/plugins/postcss-rebase-url/test/_require.cjs new file mode 100644 index 000000000..d425856aa --- /dev/null +++ b/plugins/postcss-rebase-url/test/_require.cjs @@ -0,0 +1,6 @@ +const assert = require('assert'); +const plugin = require('@csstools/postcss-rebase-url'); +plugin(); + +assert.ok(plugin.postcss, 'should have "postcss flag"'); +assert.equal(typeof plugin, 'function', 'should return a function'); diff --git a/plugins/postcss-rebase-url/test/basic.css b/plugins/postcss-rebase-url/test/basic.css new file mode 100644 index 000000000..806cb9f7f --- /dev/null +++ b/plugins/postcss-rebase-url/test/basic.css @@ -0,0 +1,9 @@ +@import "imports/basic.css"; + +.root { + background: url(./images/green.png); +} + +.outside_root { + background: url("../../../images/green.png"); +} diff --git a/plugins/postcss-rebase-url/test/basic.expect.css b/plugins/postcss-rebase-url/test/basic.expect.css new file mode 100644 index 000000000..e0d3cb681 --- /dev/null +++ b/plugins/postcss-rebase-url/test/basic.expect.css @@ -0,0 +1,44 @@ +.foo { + background: url("images/green.png"); +} + +.bar { + background: url(imports/green.png); +} + +.url-modifier { + background: url("images/green.png" something); +} + +.fragment { + background: url("images/green.png#foo"); + background: url("#foo"); +} + +.search { + background: url("images/green.png?foo=bar"); +} + +.leading_slash { + background: url("/test/images/green.png"); +} + +.full_url { + background: url("https://example.com/images/green.png"); +} + +.unknown_protocol { + background: url("foo://example.com/images/green.png"); +} + +.node_modules_protocol { + background: url("node_modules://images/green.png"); +} + +.root { + background: url(images/green.png); +} + +.outside_root { + background: url("../../../images/green.png"); +} diff --git a/plugins/postcss-rebase-url/test/examples/example.css b/plugins/postcss-rebase-url/test/examples/example.css new file mode 100644 index 000000000..ec2d75f34 --- /dev/null +++ b/plugins/postcss-rebase-url/test/examples/example.css @@ -0,0 +1 @@ +@import url("imports/basic.css"); diff --git a/plugins/postcss-rebase-url/test/examples/example.expect.css b/plugins/postcss-rebase-url/test/examples/example.expect.css new file mode 100644 index 000000000..d88049f89 --- /dev/null +++ b/plugins/postcss-rebase-url/test/examples/example.expect.css @@ -0,0 +1,3 @@ +.foo { + background: url("../images/green.png"); +} diff --git a/plugins/postcss-rebase-url/test/examples/imports/basic.css b/plugins/postcss-rebase-url/test/examples/imports/basic.css new file mode 100644 index 000000000..4504bf94c --- /dev/null +++ b/plugins/postcss-rebase-url/test/examples/imports/basic.css @@ -0,0 +1,3 @@ +.foo { + background: url('../../images/green.png'); +} diff --git a/plugins/postcss-rebase-url/test/images/green.png b/plugins/postcss-rebase-url/test/images/green.png new file mode 100644 index 0000000000000000000000000000000000000000..3171cfbaff7e291406850547f61e7ed77614d24e GIT binary patch literal 107 zcmeAS@N?(olHy`uVBq!ia0y~yV6*{ZGe%~h$gzMu%0P-az$e6&p@Ct}&!rQATxCxe t$B>G+w+9UwfxJTszn5=2;+Fto!vUY+G6qK9&zW{05l>e?mvv4FO#rrO7&QO@ literal 0 HcmV?d00001 diff --git a/plugins/postcss-rebase-url/test/imports/basic.css b/plugins/postcss-rebase-url/test/imports/basic.css new file mode 100644 index 000000000..566fcbbb2 --- /dev/null +++ b/plugins/postcss-rebase-url/test/imports/basic.css @@ -0,0 +1,36 @@ +.foo { + background: url('../images/green.png'); +} + +.bar { + background: url(green.png); +} + +.url-modifier { + background: url("../images/green.png" something); +} + +.fragment { + background: url("../images/green.png#foo"); + background: url("#foo"); +} + +.search { + background: url("../images/green.png?foo=bar"); +} + +.leading_slash { + background: url("/test/images/green.png"); +} + +.full_url { + background: url("https://example.com/images/green.png"); +} + +.unknown_protocol { + background: url("foo://example.com/images/green.png"); +} + +.node_modules_protocol { + background: url("node_modules://images/green.png"); +} diff --git a/plugins/postcss-rebase-url/test/imports/green.png b/plugins/postcss-rebase-url/test/imports/green.png new file mode 100644 index 0000000000000000000000000000000000000000..3171cfbaff7e291406850547f61e7ed77614d24e GIT binary patch literal 107 zcmeAS@N?(olHy`uVBq!ia0y~yV6*{ZGe%~h$gzMu%0P-az$e6&p@Ct}&!rQATxCxe t$B>G+w+9UwfxJTszn5=2;+Fto!vUY+G6qK9&zW{05l>e?mvv4FO#rrO7&QO@ literal 0 HcmV?d00001 diff --git a/plugins/postcss-rebase-url/test/unit/basic.mjs b/plugins/postcss-rebase-url/test/unit/basic.mjs new file mode 100644 index 000000000..02ffe4596 --- /dev/null +++ b/plugins/postcss-rebase-url/test/unit/basic.mjs @@ -0,0 +1,92 @@ +import assert from 'assert'; +import { rebase } from '../../src/rebase.mjs'; + +{ + assert.equal( + rebase( + 'foo.png', + '/assets/css', + '/assets/css', + '/assets/css', + ), + 'foo.png', + ); +} + +{ + assert.equal( + rebase( + 'foo.png', + '/assets/css/components', + '/assets/css', + '/assets/css', + ), + 'components/foo.png', + ); +} + +{ + assert.equal( + rebase( + '/foo.png', + '/assets/css', + '/assets/css', + ), + '/foo.png', + ); +} + +{ + assert.equal( + rebase( + '/foo.png', + '/assets/css/components', + '/assets/css', + ), + '/foo.png', + ); +} + +{ + assert.equal( + rebase( + '../images/foo.png', + '/assets/css/components', + '/assets/css', + ), + 'images/foo.png', + ); +} + +{ + assert.equal( + rebase( + 'images/foo.png', + '/assets/css/components', + '/assets/css', + ), + 'components/images/foo.png', + ); +} + +{ + assert.equal( + rebase( + '../../images/foo.png', + '/assets/css/components', + '/assets/css', + ), + '../images/foo.png', + ); +} + +{ + assert.equal( + rebase( + '../../images/foo.png', + '/blocks/components', + '/assets/css', + ), + '../../images/foo.png', + ); +} diff --git a/plugins/postcss-rebase-url/test/unit/ignored.mjs b/plugins/postcss-rebase-url/test/unit/ignored.mjs new file mode 100644 index 000000000..614fc9e9f --- /dev/null +++ b/plugins/postcss-rebase-url/test/unit/ignored.mjs @@ -0,0 +1,60 @@ +import assert from 'assert'; +import { rebase } from '../../src/rebase.mjs'; + +{ + assert.equal( + rebase( + 'https://example.com/foo.png', + '/assets/css/components', + '/assets/css', + '/assets/css', + ), + false, + ); +} + +{ + assert.equal( + rebase( + '//example.com/foo.png', + '/assets/css/components', + '/assets/css', + '/assets/css', + ), + false, + ); +} + +{ + assert.equal( + rebase( + 'example.com:8080/foo.png', + '/assets/css/components', + '/assets/css', + ), + false, + ); +} + +{ + // Not distinguishable from a relative URL. + assert.equal( + rebase( + 'example.com/foo.png', + '/assets/css/components', + '/assets/css', + ), + 'components/example.com/foo.png', + ); +} + +{ + assert.equal( + rebase( + 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==', + '/assets/css/components', + '/assets/css', + ), + false, + ); +} diff --git a/plugins/postcss-rebase-url/test/unit/index.mjs b/plugins/postcss-rebase-url/test/unit/index.mjs new file mode 100644 index 000000000..c07e3b140 --- /dev/null +++ b/plugins/postcss-rebase-url/test/unit/index.mjs @@ -0,0 +1,3 @@ +import './ignored.mjs'; +import './basic.mjs'; +import './unexpected-urls.mjs'; diff --git a/plugins/postcss-rebase-url/test/unit/unexpected-urls.mjs b/plugins/postcss-rebase-url/test/unit/unexpected-urls.mjs new file mode 100644 index 000000000..348652310 --- /dev/null +++ b/plugins/postcss-rebase-url/test/unit/unexpected-urls.mjs @@ -0,0 +1,90 @@ +import assert from 'assert'; +import { rebase } from '../../src/rebase.mjs'; + +{ + assert.equal( + rebase( + '@foo/bar', + '/assets/css', + '/assets/css', + ), + '@foo/bar', + ); +} + +{ + assert.equal( + rebase( + '@foo/bar', + '/assets/css/components', + '/assets/css', + ), + 'components/@foo/bar', + ); +} + +{ + assert.equal( + rebase( + '~foo/bar', + '/assets/css', + '/assets/css', + ), + '~foo/bar', + ); +} + +{ + assert.equal( + rebase( + '~foo/bar', + '/assets/css/components', + '/assets/css', + ), + 'components/~foo/bar', + ); +} + +{ + assert.equal( + rebase( + '~/foo/bar', + '/assets/css', + '/assets/css', + ), + '~/foo/bar', + ); +} + +{ + assert.equal( + rebase( + '~/foo/bar', + '/assets/css/components', + '/assets/css', + ), + 'components/~/foo/bar', + ); +} + +{ + assert.equal( + rebase( + 'node_modules:/foo/bar', + '/assets/css', + '/assets/css', + ), + 'node_modules:/foo/bar', + ); +} + +{ + assert.equal( + rebase( + 'node_modules:/foo/bar', + '/assets/css/components', + '/assets/css', + ), + 'components/node_modules:/foo/bar', + ); +} diff --git a/plugins/postcss-rebase-url/tsconfig.json b/plugins/postcss-rebase-url/tsconfig.json new file mode 100644 index 000000000..500af6d26 --- /dev/null +++ b/plugins/postcss-rebase-url/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "declarationDir": ".", + "strict": true + }, + "include": ["./src/**/*"], + "exclude": ["dist"] +}