Skip to content

Commit

Permalink
feat(transform): add stylis plugin to handle ":global()" (#37)
Browse files Browse the repository at this point in the history
* feat(transform): add stylis plugin to handle ":global()"

* Create nice-dingos-peel.md
  • Loading branch information
layershifter authored Jan 22, 2024
1 parent f986670 commit ec051b7
Show file tree
Hide file tree
Showing 5 changed files with 315 additions and 53 deletions.
5 changes: 5 additions & 0 deletions .changeset/nice-dingos-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@wyw-in-js/transform": patch
---

feat: add stylis plugin to handle ":global()"
2 changes: 1 addition & 1 deletion packages/transform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;}"`
);
});
});
});
Original file line number Diff line number Diff line change
@@ -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<typeof getGlobalSelectorModifiers>;
})
| 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;
}
53 changes: 1 addition & 52 deletions packages/transform/src/transform/generators/extract.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down

0 comments on commit ec051b7

Please sign in to comment.