Skip to content

Commit

Permalink
[pigment-css][nextjs] Allow usage of url() CSS function (#41758)
Browse files Browse the repository at this point in the history
  • Loading branch information
brijeshb42 committed Apr 8, 2024
1 parent 53cc684 commit 300a60e
Show file tree
Hide file tree
Showing 10 changed files with 194 additions and 14 deletions.
1 change: 1 addition & 0 deletions apps/pigment-css-next-app/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default function RootLayout(props: { children: React.ReactNode }) {
className={`${inter.className} ${css`
background-color: ${({ theme: t }) => t.vars.palette.background.default};
color: ${({ theme: t }) => t.vars.palette.text.primary};
background-image: url('@/assets/mui.svg');
`}`}
>
<AppRouterCacheProvider>
Expand Down
1 change: 1 addition & 0 deletions apps/pigment-css-next-app/src/assets/mui.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/pigment-css-nextjs-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function withPigment(nextConfig: NextConfig, pigmentConfig?: PigmentOptio
isServer,
outputCss: dev || hasAppDir || !isServer,
placeholderCssFile: extractionFile,
projectPath: dir,
},
async asyncResolve(what: string, importer: string, stack: string[]) {
// Using the same stub file as "next/font". Should be updated in future to
Expand Down
5 changes: 5 additions & 0 deletions packages/pigment-css-unplugin/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"rules": {
"import/prefer-default-export": "off"
}
}
9 changes: 7 additions & 2 deletions packages/pigment-css-unplugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
"watch": "tsup --watch --tsconfig tsconfig.build.json",
"copy-license": "node ../../scripts/pigment-license.mjs",
"build": "tsup --tsconfig tsconfig.build.json",
"typecheck": "tsc --noEmit -p ."
"typecheck": "tsc --noEmit -p .",
"test": "cd ../../ && cross-env NODE_ENV=test mocha 'packages/pigment-css-unplugin/**/*.test.{js,ts,tsx}'",
"test:ci": "cd ../../ && cross-env NODE_ENV=test BABEL_ENV=coverage nyc --reporter=lcov --report-dir=./coverage/pigment-css-unplugin mocha 'packages/pigment-css-unplugin/**/*.test.{js,ts,tsx}'"
},
"dependencies": {
"@babel/core": "^7.24.4",
Expand All @@ -36,7 +38,10 @@
"unplugin": "^1.7.1"
},
"devDependencies": {
"@types/babel__core": "^7.20.5"
"@types/babel__core": "^7.20.5",
"@types/chai": "^4.3.14",
"@types/mocha": "^10.0.6",
"chai": "^4.4.1"
},
"sideEffects": false,
"publishConfig": {
Expand Down
35 changes: 24 additions & 11 deletions packages/pigment-css-unplugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ import {
} from '@pigment-css/react/utils';
import type { ResolvePluginInstance } from 'webpack';

import { handleUrlReplacement, type AsyncResolver } from './utils';

type NextMeta = {
type: 'next';
dev: boolean;
isServer: boolean;
outputCss: boolean;
placeholderCssFile: string;
projectPath: string;
};

type ViteMeta = {
Expand All @@ -42,7 +45,6 @@ type WebpackMeta = {
};

type Meta = NextMeta | ViteMeta | WebpackMeta;
export type AsyncResolver = (what: string, importer: string, stack: string[]) => Promise<string>;

export type PigmentOptions<Theme extends BaseTheme = BaseTheme> = {
theme?: Theme;
Expand Down Expand Up @@ -119,7 +121,7 @@ export const plugin = createUnplugin<PigmentOptions, true>((options) => {
const isNext = meta?.type === 'next';
const outputCss = isNext && meta.outputCss;
const babelTransformPlugin: UnpluginOptions = {
name: 'zero-plugin-transform-babel',
name: 'pigment-css-plugin-transform-babel',
enforce: 'post',
transformInclude(id) {
return isZeroRuntimeProcessableFile(id, transformLibraries);
Expand All @@ -140,6 +142,7 @@ export const plugin = createUnplugin<PigmentOptions, true>((options) => {
};
},
};
const projectPath = meta?.type === 'next' ? meta.projectPath : process.cwd();

let webpackResolver: AsyncResolver;

Expand All @@ -159,7 +162,7 @@ export const plugin = createUnplugin<PigmentOptions, true>((options) => {
};

const wywInJSTransformPlugin: UnpluginOptions = {
name: 'zero-plugin-transform-wyw-in-js',
name: 'pigment-css-plugin-transform-wyw-in-js',
enforce: 'post',
buildEnd() {
onDone(process.cwd());
Expand Down Expand Up @@ -255,14 +258,24 @@ export const plugin = createUnplugin<PigmentOptions, true>((options) => {
};
}

if (isNext) {
// Handle url() replacement in css. Only handled in Next.js as the css is injected
// through the use of a placeholder CSS file that lies in the nextjs plugin package.
// So url paths can't be resolved relative to that file.
if (cssText && cssText.includes('url(')) {
cssText = await handleUrlReplacement(cssText, id, asyncResolve, projectPath);
}
}

if (sourceMap && result.cssSourceMapText) {
const map = Buffer.from(result.cssSourceMapText).toString('base64');
cssText += `/*# sourceMappingURL=data:application/json;base64,${map}*/`;
}

// Virtual modules do not work consistently in Next.js (the build is done at least
// thrice) resulting in error in subsequent builds. So we use a placeholder CSS
// file with the actual CSS content as part of the query params.
// thrice with different combination of parameters) resulting in error in
// subsequent builds. So we use a placeholder CSS file with the actual CSS content
// as part of the query params.
if (isNext) {
const data = `${meta.placeholderCssFile}?${encodeURIComponent(
JSON.stringify({
Expand All @@ -278,7 +291,7 @@ export const plugin = createUnplugin<PigmentOptions, true>((options) => {
}

const slug = slugify(cssText);
const cssFilename = `${slug}.zero.css`;
const cssFilename = `${slug}.pigment.css`;
const cssId = `./${cssFilename}`;
cssFileLookup.set(cssId, cssFilename);
cssLookup.set(cssFilename, cssText);
Expand All @@ -297,15 +310,15 @@ export const plugin = createUnplugin<PigmentOptions, true>((options) => {

const plugins: Array<UnpluginOptions> = [
{
name: 'zero-plugin-theme-tokens',
name: 'pigment-css-plugin-theme-tokens',
enforce: 'pre',
webpack(compiler) {
compiler.hooks.normalModuleFactory.tap(pluginName, (nmf) => {
nmf.hooks.createModule.tap(
pluginName,
// @ts-expect-error CreateData is typed as 'object'...
(createData: { matchResource?: string; settings: { sideEffects?: boolean } }) => {
if (createData.matchResource && createData.matchResource.endsWith('.zero.css')) {
if (createData.matchResource && createData.matchResource.endsWith('.pigment.css')) {
createData.settings.sideEffects = true;
}
},
Expand Down Expand Up @@ -371,13 +384,13 @@ export const plugin = createUnplugin<PigmentOptions, true>((options) => {
// This is already handled separately for Next.js using `placeholderCssFile`
if (!isNext) {
plugins.push({
name: 'zero-plugin-load-output-css',
name: 'pigment-css-plugin-load-output-css',
enforce: 'pre',
resolveId(source: string) {
return cssFileLookup.get(source);
},
loadInclude(id) {
return id.endsWith('.zero.css');
return id.endsWith('.pigment.css');
},
load(id) {
return cssLookup.get(id) ?? '';
Expand All @@ -392,4 +405,4 @@ export const webpack = plugin.webpack as unknown as UnpluginFactoryOutput<
WebpackPluginInstance
>;

export { extendTheme };
export { type AsyncResolver, extendTheme };
58 changes: 58 additions & 0 deletions packages/pigment-css-unplugin/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as path from 'node:path';

export type AsyncResolver = (what: string, importer: string, stack: string[]) => Promise<string>;

/**
* There might be a better way to do this in future, but due to the async
* nature of the resolver, this is the best way currently to replace url()
* content references with the absolute path of the referenced file.
* This is because WyW-in-JS's preprocessor is a sync call. So we can't resolve
* paths in this call asyncronously.
* The upside is that we can use aliases in the url() references as well
* alongside relative paths.
*/
export const handleUrlReplacement = async (
cssText: string,
filename: string,
asyncResolver: AsyncResolver,
projectPath: string,
) => {
// [0] [1][2] [3] [4]
const urlRegex = /\b(url\((["']?))(\.?[^)]+?)(\2\))/g;
let newCss = '';
let match = urlRegex.exec(cssText);
let lastIndex = 0;
while (match) {
newCss += cssText.substring(lastIndex, match.index);
const mainItem = match[3];
// no need to handle data uris or absolute paths
if (
mainItem[0] === '/' ||
mainItem.startsWith('data:') ||
mainItem.startsWith('http:') ||
mainItem.startsWith('https:')
) {
newCss += `url(${mainItem})`;
} else {
// eslint-disable-next-line no-await-in-loop
const resolvedAbsolutePath = await asyncResolver(mainItem, filename, []);
if (!resolvedAbsolutePath) {
newCss += `url(${mainItem})`;
} else {
let pathFromRoot = resolvedAbsolutePath.replace(projectPath, '');
// Need to do this for Windows paths
pathFromRoot = pathFromRoot.split(path.sep).join('/');
// const relativePathToProjectRoot = path.relative()
// Next.js expects the path to be relative to the project root and starting with ~
newCss += `url(~${pathFromRoot})`;
}
}
lastIndex = match.index + match[0].length;
match = urlRegex.exec(cssText);
}
newCss += cssText.substring(lastIndex);
if (!newCss) {
return cssText;
}
return newCss;
};
86 changes: 86 additions & 0 deletions packages/pigment-css-unplugin/tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { expect } from 'chai';
import { handleUrlReplacement } from '../src/utils';

const DATA_URI =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIwLjUgMTMuN2';
const HTML_LOGO_URL = 'https://mui.com/static/logo.svg';
const ABSOLUTE_PATH = '/static/logo.svg';
const dummyResolver = (url: string) => {
return Promise.resolve(url);
};

describe('utils', () => {
describe('handleUrlReplacement', () => {
it('should not replace http/data/absolute urls', async () => {
[DATA_URI, HTML_LOGO_URL, ABSOLUTE_PATH].forEach(async (url) => {
const cssString1 = `.className {
background-image: url(${url});
}`;
const cssString2 = `.className {
background-image: url('${url}');
}`;
expect(
await handleUrlReplacement(
cssString1,
'/path/to/project/filename.ts',
dummyResolver,
'/path/to/project',
),
).to.equal(cssString1);
expect(
await handleUrlReplacement(
cssString2,
'/path/to/project/filename.ts',
dummyResolver,
'/path/to/project',
),
).to.equal(cssString1);
});
});

it('should replace relative or aliased paths with paths relative to the current working directory', async () => {
const projectPath = '/path/to/project';
const filePath = `${projectPath}/src/components/Slider.tsx`;
const resolver = (url: string): Promise<string> => {
return new Promise((resolve) => {
if (url.startsWith('@/')) {
resolve(`${projectPath}/src${url.slice(1)}`);
} else if (url.startsWith('../../')) {
resolve(`${projectPath}/src/${url.replace('../../', '')}`);
} else if (url.startsWith('/')) {
resolve(url);
}
});
};
const cssString = `.className_c17ksbvo{
background-color:var(--mui-palette-background-default, #fff);
color:var(--mui-palette-text-primary, rgba(0, 0, 0, 0.87));
background-image: url('${DATA_URI}');
background-image: url('${HTML_LOGO_URL}');
background-image: url(${ABSOLUTE_PATH});
background-image: url('../../assets/mui.svg');
background-image: url('@/assets/mui.svg');
background-image: url('/assets/mui.svg');
background-image: url('@/assets/mui.svg');
display: flex;
position: absolute;
}`;
const expectedCssString = `.className_c17ksbvo{
background-color:var(--mui-palette-background-default, #fff);
color:var(--mui-palette-text-primary, rgba(0, 0, 0, 0.87));
background-image: url(${DATA_URI});
background-image: url(${HTML_LOGO_URL});
background-image: url(${ABSOLUTE_PATH});
background-image: url(~/src/assets/mui.svg);
background-image: url(~/src/assets/mui.svg);
background-image: url(/assets/mui.svg);
background-image: url(~/src/assets/mui.svg);
display: flex;
position: absolute;
}`;
expect(await handleUrlReplacement(cssString, filePath, resolver, projectPath)).to.equal(
expectedCssString,
);
});
});
});
3 changes: 2 additions & 1 deletion packages/pigment-css-unplugin/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"@mui/utils/*": ["./packages/mui-utils/src/*"],
"@pigment-css/react": ["./packages/pigment-css-react/src"],
"@pigment-css/react/*": ["./packages/pigment-css-react/src/*"]
}
},
"types": ["node", "mocha", "chai"]
},
"include": ["src/**/*.ts"],
"exclude": ["./tsup.config.ts"]
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 300a60e

Please sign in to comment.