From ec051b79183c5c7ca69358afba00c25a09eb05e0 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 22 Jan 2024 10:27:08 +0100 Subject: [PATCH] feat(transform): add stylis plugin to handle ":global()" (#37) * feat(transform): add stylis plugin to handle ":global()" * Create nice-dingos-peel.md --- .changeset/nice-dingos-peel.md | 5 + packages/transform/src/index.ts | 2 +- .../__tests__/createStylisProcessor.test.ts | 132 +++++++++++++ .../generators/createStylisPreprocessor.ts | 176 ++++++++++++++++++ .../src/transform/generators/extract.ts | 53 +----- 5 files changed, 315 insertions(+), 53 deletions(-) create mode 100644 .changeset/nice-dingos-peel.md create mode 100644 packages/transform/src/transform/generators/__tests__/createStylisProcessor.test.ts create mode 100644 packages/transform/src/transform/generators/createStylisPreprocessor.ts diff --git a/.changeset/nice-dingos-peel.md b/.changeset/nice-dingos-peel.md new file mode 100644 index 00000000..33f26fb2 --- /dev/null +++ b/.changeset/nice-dingos-peel.md @@ -0,0 +1,5 @@ +--- +"@wyw-in-js/transform": patch +--- + +feat: add stylis plugin to handle ":global()" diff --git a/packages/transform/src/index.ts b/packages/transform/src/index.ts index e6ae74a7..762b4881 100644 --- a/packages/transform/src/index.ts +++ b/packages/transform/src/index.ts @@ -24,7 +24,7 @@ export type { LoadAndParseFn } from './transform/Entrypoint.types'; export { baseHandlers } from './transform/generators'; export { prepareCode } from './transform/generators/transform'; export { Entrypoint } from './transform/Entrypoint'; -export { transformUrl } from './transform/generators/extract'; +export { transformUrl } from './transform/generators/createStylisPreprocessor'; export { asyncResolveImports, syncResolveImports, diff --git a/packages/transform/src/transform/generators/__tests__/createStylisProcessor.test.ts b/packages/transform/src/transform/generators/__tests__/createStylisProcessor.test.ts new file mode 100644 index 00000000..3daae2d7 --- /dev/null +++ b/packages/transform/src/transform/generators/__tests__/createStylisProcessor.test.ts @@ -0,0 +1,132 @@ +import dedent from 'dedent'; +import { compile, middleware, serialize, stringify } from 'stylis'; + +import { + createStylisUrlReplacePlugin, + stylisGlobalPlugin, +} from '../createStylisPreprocessor'; + +describe('stylisUrlReplacePlugin', () => { + const filename = '/path/to/src/file.js'; + const outputFilename = '/path/to/assets/file.css'; + + const stylisUrlReplacePlugin = createStylisUrlReplacePlugin( + filename, + outputFilename + ); + + function compileRule(rule: string): string { + return serialize( + compile(rule), + middleware([stylisUrlReplacePlugin, stringify]) + ); + } + + it('should replace relative paths in url() expressions', () => { + expect( + compileRule('.component { background-image: url(./image.png) }') + ).toMatchInlineSnapshot( + `".component{background-image:url(../src/image.png);}"` + ); + }); +}); + +describe('stylisGlobalPlugin', () => { + function compileRule(rule: string): string { + return serialize( + compile(rule), + middleware([stylisGlobalPlugin, stringify]) + ); + } + + describe('inner part of :global()', () => { + it('single selector', () => { + expect( + compileRule('.component :global(.global) { color: red }') + ).toMatchInlineSnapshot(`".global {color:red;}"`); + + expect( + compileRule('.component &:global(.global) { color: red }') + ).toMatchInlineSnapshot(`".global.component {color:red;}"`); + + expect( + compileRule('.component & :global(.global) { color: red }') + ).toMatchInlineSnapshot(`".global .component {color:red;}"`); + }); + + it('multiple selectors', () => { + expect( + compileRule('.component :global(.globalA.globalB) { color: red }') + ).toMatchInlineSnapshot(`".globalA.globalB {color:red;}"`); + + expect( + compileRule('.component &:global(.globalA.globalB) { color: red }') + ).toMatchInlineSnapshot(`".globalA.globalB.component {color:red;}"`); + + expect( + compileRule('.component & :global(.globalA.globalB) { color: red }') + ).toMatchInlineSnapshot(`".globalA.globalB .component {color:red;}"`); + }); + + it('data selector', () => { + expect( + compileRule('.component :global([data-global-style]) { color: red }') + ).toMatchInlineSnapshot(`"[data-global-style] {color:red;}"`); + + expect( + compileRule('.component &:global([data-global-style]) { color: red }') + ).toMatchInlineSnapshot(`"[data-global-style].component {color:red;}"`); + + expect( + compileRule('.component & :global([data-global-style]) { color: red }') + ).toMatchInlineSnapshot(`"[data-global-style] .component {color:red;}"`); + }); + }); + + describe('nested part of :global()', () => { + it('single selector', () => { + expect( + compileRule('.component :global() { .global { color: red } }') + ).toMatchInlineSnapshot(`".global {color:red;}"`); + + expect( + compileRule('.component &:global() { .global { color: red } }') + ).toMatchInlineSnapshot(`".global.component {color:red;}"`); + + expect( + compileRule('.component & :global() { .global { color: red } }') + ).toMatchInlineSnapshot(`".global .component {color:red;}"`); + }); + + it('multiple selectors', () => { + const cssRuleA = dedent(` + .component :global() { + .globalA { color: red } + .globalB { color: blue } + } + `); + const cssRuleB = dedent(` + .component &:global() { + .globalA { color: red } + .globalB { color: blue } + } + `); + const cssRuleC = dedent(` + .component & :global() { + .globalA { color: red } + .globalB { color: blue } + } + `); + + expect(compileRule(cssRuleA)).toMatchInlineSnapshot( + `".globalA {color:red;}.globalB {color:blue;}"` + ); + expect(compileRule(cssRuleB)).toMatchInlineSnapshot( + `".globalA.component {color:red;}.globalB.component {color:blue;}"` + ); + expect(compileRule(cssRuleC)).toMatchInlineSnapshot( + `".globalA .component {color:red;}.globalB .component {color:blue;}"` + ); + }); + }); +}); diff --git a/packages/transform/src/transform/generators/createStylisPreprocessor.ts b/packages/transform/src/transform/generators/createStylisPreprocessor.ts new file mode 100644 index 00000000..adcbec93 --- /dev/null +++ b/packages/transform/src/transform/generators/createStylisPreprocessor.ts @@ -0,0 +1,176 @@ +import * as path from 'path'; +import { + compile, + middleware, + prefixer, + serialize, + stringify, + tokenize, + RULESET, +} from 'stylis'; +import type { Middleware } from 'stylis'; + +import type { Options } from '../../types'; + +const POSIX_SEP = path.posix.sep; + +export function transformUrl( + url: string, + outputFilename: string, + sourceFilename: string, + platformPath: typeof path = path +) { + // Replace asset path with new path relative to the output CSS + const relative = platformPath.relative( + platformPath.dirname(outputFilename), + // Get the absolute path to the asset from the path relative to the JS file + platformPath.resolve(platformPath.dirname(sourceFilename), url) + ); + + if (platformPath.sep === POSIX_SEP) { + return relative; + } + + return relative.split(platformPath.sep).join(POSIX_SEP); +} + +/** + * Stylis plugin that mimics :global() selector behavior from Stylis v3. + */ +export const stylisGlobalPlugin: Middleware = (element) => { + function getGlobalSelectorModifiers(value: string) { + const match = value.match(/(&\f( )?)?:global\(/); + + if (match === null) { + throw new Error( + `Failed to match :global() selector in "${value}". Please report a bug if it happens.` + ); + } + + const [, baseSelector, spaceDelimiter] = match; + + return { + includeBaseSelector: !!baseSelector, + includeSpaceDelimiter: !!spaceDelimiter, + }; + } + + switch (element.type) { + case RULESET: + if (typeof element.props === 'string') { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + `"element.props" has type "string" (${JSON.stringify( + element.props, + null, + 2 + )}), it's not expected. Please report a bug if it happens.` + ); + } + + return; + } + + Object.assign(element, { + props: element.props.map((cssSelector) => { + // Avoids calling tokenize() on every string + if (!cssSelector.includes(':global(')) { + return cssSelector; + } + + if (element.children.length === 0) { + Object.assign(element, { + global: getGlobalSelectorModifiers(element.value), + }); + return cssSelector; + } + + const { includeBaseSelector, includeSpaceDelimiter } = + ( + element.parent as unknown as + | (Element & { + global: ReturnType; + }) + | undefined + )?.global || getGlobalSelectorModifiers(element.value); + + const tokens = tokenize(cssSelector); + let selector = ''; + + for (let i = 0, len = tokens.length; i < len; i++) { + const token = tokens[i]; + + // + // Match for ":global(" + if (token === ':' && tokens[i + 1] === 'global') { + // + // Match for ":global()" + if (tokens[i + 2] === '()') { + selector = [ + ...tokens.slice(i + 4), + includeSpaceDelimiter ? ' ' : '', + ...(includeBaseSelector ? tokens.slice(0, i - 1) : []), + includeSpaceDelimiter ? '' : ' ', + ].join(''); + + break; + } + + // + // Match for ":global(selector)" + selector = [ + tokens[i + 2].slice(1, -1), + includeSpaceDelimiter ? ' ' : '', + ...(includeBaseSelector ? tokens.slice(0, i - 1) : []), + includeSpaceDelimiter ? '' : ' ', + ].join(''); + + break; + } + } + + return selector; + }), + }); + + break; + + default: + } +}; + +export function createStylisUrlReplacePlugin( + filename: string, + outputFilename: string | undefined +): Middleware { + return (element) => { + if (element.type === 'decl' && outputFilename) { + // When writing to a file, we need to adjust the relative paths inside url(..) expressions. + // It'll allow css-loader to resolve an imported asset properly. + // eslint-disable-next-line no-param-reassign + element.return = element.value.replace( + /\b(url\((["']?))(\.[^)]+?)(\2\))/g, + (match, p1, p2, p3, p4) => + p1 + transformUrl(p3, outputFilename, filename) + p4 + ); + } + }; +} + +export function createStylisPreprocessor(options: Options) { + function stylisPreprocess(selector: string, text: string): string { + const compiled = compile(`${selector} {${text}}\n`); + + return serialize( + compiled, + middleware([ + createStylisUrlReplacePlugin(options.filename, options.outputFilename), + stylisGlobalPlugin, + prefixer, + stringify, + ]) + ); + } + + return stylisPreprocess; +} diff --git a/packages/transform/src/transform/generators/extract.ts b/packages/transform/src/transform/generators/extract.ts index 215d27b0..61908b2d 100644 --- a/packages/transform/src/transform/generators/extract.ts +++ b/packages/transform/src/transform/generators/extract.ts @@ -1,62 +1,11 @@ -import path from 'path'; - import type { Mapping } from 'source-map'; import { SourceMapGenerator } from 'source-map'; -import { compile, serialize, stringify, middleware, prefixer } from 'stylis'; import type { Replacements, Rules } from '@wyw-in-js/shared'; import type { Options, PreprocessorFn } from '../../types'; import type { IExtractAction, SyncScenarioForAction } from '../types'; - -const posixSep = path.posix.sep; - -export function transformUrl( - url: string, - outputFilename: string, - sourceFilename: string, - platformPath: typeof path = path -) { - // Replace asset path with new path relative to the output CSS - const relative = platformPath.relative( - platformPath.dirname(outputFilename), - // Get the absolute path to the asset from the path relative to the JS file - platformPath.resolve(platformPath.dirname(sourceFilename), url) - ); - - if (platformPath.sep === posixSep) { - return relative; - } - - return relative.split(platformPath.sep).join(posixSep); -} - -function createStylisPreprocessor(options: Options) { - function stylisPreprocess(selector: string, text: string): string { - const compiled = compile(`${selector} {${text}}\n`); - return serialize( - compiled, - middleware([ - (element: { return: string; type: string; value: string }) => { - const { outputFilename } = options; - if (element.type === 'decl' && outputFilename) { - // When writing to a file, we need to adjust the relative paths inside url(..) expressions. - // It'll allow css-loader to resolve an imported asset properly. - // eslint-disable-next-line no-param-reassign - element.return = element.value.replace( - /\b(url\((["']?))(\.[^)]+?)(\2\))/g, - (match, p1, p2, p3, p4) => - p1 + transformUrl(p3, outputFilename, options.filename) + p4 - ); - } - }, - prefixer, - stringify, - ]) - ); - } - return stylisPreprocess; -} +import { createStylisPreprocessor } from './createStylisPreprocessor'; function extractCssFromAst( rules: Rules,