diff --git a/change/@griffel-linaria-processor-b20f4e52-7e3f-4efc-ac1d-6157f9fb99ee.json b/change/@griffel-linaria-processor-b20f4e52-7e3f-4efc-ac1d-6157f9fb99ee.json new file mode 100644 index 000000000..66ec17b54 --- /dev/null +++ b/change/@griffel-linaria-processor-b20f4e52-7e3f-4efc-ac1d-6157f9fb99ee.json @@ -0,0 +1,7 @@ +{ + "type": "major", + "comment": "chore: initial release", + "packageName": "@griffel/tag-processor", + "email": "olfedias@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/package.json b/package.json index ca70ce028..6192de27d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "type-check": "nx affected --target=type-check" }, "devDependencies": { + "@babel/generator": "^7.23.0", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "7.23.2", "@codesandbox/sandpack-react": "1.18.4", @@ -73,6 +74,7 @@ "@typescript-eslint/eslint-plugin": "5.47.0", "@typescript-eslint/parser": "5.47.0", "@uifabric/merge-styles": "7.19.1", + "@wyw-in-js/processor-utils": "^0.2.2", "babel-jest": "28.1.3", "babel-loader": "8.1.0", "babel-plugin-annotate-pure-calls": "^0.4.0", diff --git a/packages/tag-processor/.eslintrc.json b/packages/tag-processor/.eslintrc.json new file mode 100644 index 000000000..9d9c0db55 --- /dev/null +++ b/packages/tag-processor/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/tag-processor/README.md b/packages/tag-processor/README.md new file mode 100644 index 000000000..0a789d7de --- /dev/null +++ b/packages/tag-processor/README.md @@ -0,0 +1,55 @@ +# Griffel processor for Linaria + +A processor for [Linaria](https://github.com/callstack/linaria) for that performs build time transforms for `makeStyles` & `makeResetStyles` [`@griffel/react`](../react). + + + + +- [Install](#install) +- [How to use it?](#how-to-use-it) + - [Handling Griffel re-exports](#handling-griffel-re-exports) + + + +## Install + +```bash +yarn add --dev @griffel/tag-processor +# or +npm install --save-dev @griffel/tag-processor +``` + +## How to use it? + +This package cannot be used solely, it should be paired with `@griffel/babel-preset` or `@griffel/webpack-loader` + +- For library developers, please use [`@griffel/babel-preset`](../babel-preset) +- For application developers, please use [`@griffel/webpack-loader`](../webpack-loader) + +### Handling Griffel re-exports + +```js +import { makeStyles, makeResetStyles } from 'custom-package'; +``` + +By default, the processor handles imports from `@griffel/react` & `@fluentui/react-components`, to handle imports from custom packages settings you need to include meta information to a matching `package.json`: + +```json +{ + "name": "custom-package", + "version": "1.0.0", + "linaria": { + "tags": { + "makeStyles": "@griffel/tag-processor/make-styles", + "makeResetStyles": "@griffel/tag-processor/make-reset-styles" + } + } +} +``` + +> **Note**: "custom-package" should re-export following functions from `@griffel/react`: +> +> - `__styles` +> - `__css` +> - `__resetStyles` +> - `__resetCSS` diff --git a/packages/tag-processor/jest.config.js b/packages/tag-processor/jest.config.js new file mode 100644 index 000000000..8fb101de1 --- /dev/null +++ b/packages/tag-processor/jest.config.js @@ -0,0 +1,14 @@ +module.exports = { + displayName: 'tag-processor', + preset: '../../jest.preset.js', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + transform: { + '^.+\\.[tj]sx?$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/packages/tag-processor', +}; diff --git a/packages/tag-processor/package.json b/packages/tag-processor/package.json new file mode 100644 index 000000000..2ea054832 --- /dev/null +++ b/packages/tag-processor/package.json @@ -0,0 +1,30 @@ +{ + "name": "@griffel/tag-processor", + "version": "0.0.1", + "description": "Linaria processor for Griffel", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/griffel" + }, + "dependencies": { + "@griffel/core": "^1.14.2", + "@linaria/tags": "^5.0.1", + "stylis": "^4.2.0", + "tslib": "^2.1.0" + }, + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.js" + }, + "./make-styles": { + "types": "./src/MakeStylesProcessor.d.ts", + "default": "./src/MakeStylesProcessor.js" + }, + "./make-reset-styles": { + "types": "./src/MakeResetStylesProcessor.d.ts", + "default": "./src/MakeResetStylesProcessor.js" + } + } +} diff --git a/packages/tag-processor/project.json b/packages/tag-processor/project.json new file mode 100644 index 000000000..1dcfc05f5 --- /dev/null +++ b/packages/tag-processor/project.json @@ -0,0 +1,49 @@ +{ + "root": "packages/tag-processor", + "sourceRoot": "packages/tag-processor/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/tag-processor/**/*.ts"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["coverage/packages/tag-processor"], + "options": { + "jestConfig": "packages/tag-processor/jest.config.js", + "passWithNoTests": true + } + }, + "build": { + "executor": "@nrwl/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/tag-processor", + "tsConfig": "packages/tag-processor/tsconfig.lib.json", + "packageJson": "packages/tag-processor/package.json", + "main": "packages/tag-processor/src/index.ts", + "assets": [ + "packages/tag-processor/README.md", + { + "glob": "LICENSE.md", + "input": ".", + "output": "." + } + ] + } + }, + "type-check": { + "executor": "@nrwl/workspace:run-commands", + "options": { + "cwd": "packages/tag-processor", + "commands": [{ "command": "tsc -b --pretty" }], + "outputPath": [] + } + } + }, + "tags": [] +} diff --git a/packages/tag-processor/src/BaseGriffelProcessor.ts b/packages/tag-processor/src/BaseGriffelProcessor.ts new file mode 100644 index 000000000..f2f3d6e83 --- /dev/null +++ b/packages/tag-processor/src/BaseGriffelProcessor.ts @@ -0,0 +1,47 @@ +import type { Expression } from '@babel/types'; +import type { Params, TailProcessorParams } from '@wyw-in-js/processor-utils'; +import { BaseProcessor, TaggedTemplateProcessor, validateParams } from '@wyw-in-js/processor-utils'; +import * as path from 'path'; + +export default abstract class BaseGriffelProcessor extends BaseProcessor { + readonly expressionName: string | number | boolean | null = null; + + public constructor([tag, callParam]: Params, ...args: TailProcessorParams) { + super([tag], ...args); + + validateParams([tag, callParam], ['callee', 'call'], TaggedTemplateProcessor.SKIP); + + if (callParam[0] === 'call') { + const { ex } = callParam[1]; + + if (ex.type === 'Identifier') { + this.dependencies.push(callParam[1] as any); + this.expressionName = ex.name; + } else if (ex.type === 'NullLiteral') { + this.expressionName = null; + } else { + this.expressionName = ex.value; + } + } + } + + public get path() { + return process.platform === 'win32' ? path.win32 : path.posix; + } + + public override get asSelector(): string { + throw new Error('The result of makeStyles cannot be used as a selector.'); + } + + public override doEvaltimeReplacement(): void { + this.replacer(this.value, false); + } + + public override get value(): Expression { + return this.astService.nullLiteral(); + } + + public override toString(): string { + return `${super.toString()}(…)`; + } +} diff --git a/packages/tag-processor/src/MakeResetStylesProcessor.ts b/packages/tag-processor/src/MakeResetStylesProcessor.ts new file mode 100644 index 000000000..1dd9cdf23 --- /dev/null +++ b/packages/tag-processor/src/MakeResetStylesProcessor.ts @@ -0,0 +1,66 @@ +import { resolveResetStyleRules } from '@griffel/core'; +import type { CSSRulesByBucket, GriffelResetStyle } from '@griffel/core'; +import type { ValueCache } from '@wyw-in-js/processor-utils'; + +import { createRuleLiteral } from './assets/createRuleLiteral'; +import { normalizeStyleRules } from './assets/normalizeStyleRules'; +import BaseGriffelProcessor from './BaseGriffelProcessor'; +import { FileContext } from './types'; + +export default class MakeResetStylesProcessor extends BaseGriffelProcessor { + #ltrClassName: string | null = null; + #rtlClassName: string | null = null; + #cssRules: CSSRulesByBucket | string[] | null = null; + + public override build(valueCache: ValueCache) { + const styles = valueCache.get(this.expressionName) as GriffelResetStyle; + + [this.#ltrClassName, this.#rtlClassName, this.#cssRules] = resolveResetStyleRules( + // Heads up! + // Style rules should be normalized *before* they will be resolved to CSS rules to have deterministic + // results across different build targets. + normalizeStyleRules(this.path, this.context as FileContext, styles), + ); + } + + public override doRuntimeReplacement(): void { + if (!this.#cssRules || !this.#ltrClassName) { + throw new Error('Styles are not extracted yet. Please call `build` first.'); + } + + const t = this.astService; + const addAssetImport = (path: string) => t.addDefaultImport(path, 'asset'); + + let rulesExpression; + + if (Array.isArray(this.#cssRules)) { + rulesExpression = t.arrayExpression( + this.#cssRules.map(rule => { + return createRuleLiteral(this.path, t, this.context as FileContext, rule, addAssetImport); + }), + ); + } else { + rulesExpression = t.objectExpression( + Object.entries(this.#cssRules).map(([bucketName, cssRules]) => + t.objectProperty( + t.identifier(bucketName), + t.arrayExpression( + cssRules.map(rule => { + return createRuleLiteral(this.path, t, this.context as FileContext, rule as string, addAssetImport); + }), + ), + ), + ), + ); + } + + const stylesImportIdentifier = t.addNamedImport('__resetStyles', this.tagSource.source); + const stylesCallExpression = t.callExpression(stylesImportIdentifier, [ + t.stringLiteral(this.#ltrClassName), + this.#rtlClassName ? t.stringLiteral(this.#rtlClassName) : t.nullLiteral(), + rulesExpression, + ]); + + this.replacer(stylesCallExpression, true); + } +} diff --git a/packages/tag-processor/src/MakeStylesProcessor.ts b/packages/tag-processor/src/MakeStylesProcessor.ts new file mode 100644 index 000000000..d8413abb6 --- /dev/null +++ b/packages/tag-processor/src/MakeStylesProcessor.ts @@ -0,0 +1,69 @@ +import { GriffelStyle, resolveStyleRulesForSlots } from '@griffel/core'; +import type { CSSClassesMapBySlot, CSSRulesByBucket } from '@griffel/core'; +import type { ValueCache } from '@wyw-in-js/processor-utils'; + +import { createRuleLiteral } from './assets/createRuleLiteral'; +import { normalizeStyleRules } from './assets/normalizeStyleRules'; +import BaseGriffelProcessor from './BaseGriffelProcessor'; +import type { FileContext } from './types'; +import { dedupeCSSRules } from './utils/dedupeCSSRules'; + +export default class MakeStylesProcessor extends BaseGriffelProcessor { + #cssClassMap: CSSClassesMapBySlot | undefined; + #cssRulesByBucket: CSSRulesByBucket | undefined; + + public override build(valueCache: ValueCache) { + const stylesBySlots = valueCache.get(this.expressionName) as Record; + + [this.#cssClassMap, this.#cssRulesByBucket] = resolveStyleRulesForSlots( + // Heads up! + // Style rules should be normalized *before* they will be resolved to CSS rules to have deterministic + // results across different build targets. + normalizeStyleRules(this.path, this.context as FileContext, stylesBySlots), + ); + } + + public override doRuntimeReplacement(): void { + if (!this.#cssClassMap || !this.#cssRulesByBucket) { + throw new Error('Styles are not extracted yet. Please call `build` first.'); + } + + const t = this.astService; + const addAssetImport = (path: string) => t.addDefaultImport(path, 'asset'); + + const uniqueRules = dedupeCSSRules(this.#cssRulesByBucket); + const rulesObjectExpression = t.objectExpression( + Object.entries(uniqueRules).map(([bucketName, cssRules]) => + t.objectProperty( + t.identifier(bucketName), + t.arrayExpression( + cssRules.map(rule => { + if (typeof rule === 'string') { + return createRuleLiteral(this.path, t, this.context as FileContext, rule, addAssetImport); + } + + const [cssRule, metadata] = rule; + + return t.arrayExpression([ + createRuleLiteral(this.path, t, this.context as FileContext, cssRule, addAssetImport), + t.objectExpression( + Object.entries(metadata).map(([key, value]) => + t.objectProperty(t.identifier(key), t.stringLiteral(value as string)), + ), + ), + ]); + }), + ), + ), + ), + ); + + const stylesImportIdentifier = t.addNamedImport('__styles', this.tagSource.source); + const stylesCallExpression = t.callExpression(stylesImportIdentifier, [ + t.valueToNode(this.#cssClassMap), + rulesObjectExpression, + ]); + + this.replacer(stylesCallExpression, true); + } +} diff --git a/packages/tag-processor/src/assets/createRuleLiteral.test.ts b/packages/tag-processor/src/assets/createRuleLiteral.test.ts new file mode 100644 index 000000000..27c6cb1b0 --- /dev/null +++ b/packages/tag-processor/src/assets/createRuleLiteral.test.ts @@ -0,0 +1,61 @@ +import { types as t } from '@babel/core'; +import generate from '@babel/generator'; +import * as path from 'path'; + +import type { FileContext } from '../types'; +import { createRuleLiteral } from './createRuleLiteral'; + +const fileContextPosix: FileContext = { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', +}; +const fileContextWin32: FileContext = { + root: 'C:\\Users\\Foo\\projects', + filename: 'C:\\Users\\Foo\\projects\\src\\styles\\Component.styles.ts', +}; + +describe('createRuleLiteral', () => { + it('creates strings for rules', () => { + const addAssetImport = jest.fn(); + + const node = createRuleLiteral(path.posix, t, fileContextPosix, '.foo { color: red }', addAssetImport); + const { code } = generate(node); + + expect(code).toMatchInlineSnapshot(`"\\".foo { color: red }\\""`); + expect(addAssetImport).not.toHaveBeenCalled(); + }); + + describe('updates URLs that start with "griffel"', () => { + it('posix', () => { + const addAssetImport = jest.fn().mockImplementation(() => t.identifier('foo')); + + const node = createRuleLiteral( + path.posix, + t, + fileContextPosix, + '.foo { url(griffel:assets/foo.png) }', + addAssetImport, + ); + const { code } = generate(node); + + expect(code).toMatchInlineSnapshot(`"\`.foo { url(\${foo}) }\`"`); + expect(addAssetImport).toHaveBeenCalledWith('../../assets/foo.png'); + }); + + it('win32', () => { + const addAssetImport = jest.fn().mockImplementation(() => t.identifier('foo')); + + const node = createRuleLiteral( + path.win32, + t, + fileContextWin32, + '.foo { url(griffel:assets/foo.png) }', + addAssetImport, + ); + const { code } = generate(node); + + expect(code).toMatchInlineSnapshot(`"\`.foo { url(\${foo}) }\`"`); + expect(addAssetImport).toHaveBeenCalledWith('../../assets/foo.png'); + }); + }); +}); diff --git a/packages/tag-processor/src/assets/createRuleLiteral.ts b/packages/tag-processor/src/assets/createRuleLiteral.ts new file mode 100644 index 000000000..fbe7252c9 --- /dev/null +++ b/packages/tag-processor/src/assets/createRuleLiteral.ts @@ -0,0 +1,51 @@ +import type { types as t } from '@babel/core'; +import { tokenize } from 'stylis'; + +import { relativePathToImportLike } from './relativePathToImportLike'; +import type { FileContext } from '../types'; + +export function createRuleLiteral( + path: typeof import('path'), + astService: typeof t, + fileContext: FileContext, + rule: string, + addAssetImport: (path: string) => t.Identifier, +): t.StringLiteral | t.TemplateLiteral { + if (rule.indexOf('url(') === -1) { + return astService.stringLiteral(rule); + } + + const tokens = tokenize(rule); + + const quasis: t.TemplateElement[] = []; + const expressions: t.Identifier[] = []; + + let acc = ''; + + for (let i = 0, l = tokens.length; i < l; i++) { + acc += tokens[i]; + + if (tokens[i] === 'url') { + const url = tokens[i + 1].slice(1, -1); + + if (url.startsWith('griffel:')) { + // Handle `filter: url(./a.svg#id)` + const [pathname, hash] = url.slice(8).split('#'); + + quasis.push(astService.templateElement({ raw: acc + '(' }, false)); + + const importPath = relativePathToImportLike(path, fileContext, pathname); + const importName = addAssetImport(importPath); + + expressions.push(importName); + + acc = `${hash ? `#${hash}` : ''})`; + i++; + } + } + } + + quasis.push(astService.templateElement({ raw: acc }, true)); + + return astService.templateLiteral(quasis, expressions); +} diff --git a/packages/tag-processor/src/assets/normalizeStyleRules.test.ts b/packages/tag-processor/src/assets/normalizeStyleRules.test.ts new file mode 100644 index 000000000..4b881269c --- /dev/null +++ b/packages/tag-processor/src/assets/normalizeStyleRules.test.ts @@ -0,0 +1,187 @@ +import * as path from 'path'; +import { normalizeStyleRule, normalizeStyleRules } from './normalizeStyleRules'; + +describe('normalizeStyleRule', () => { + it('handles rules without quotes', () => { + expect( + normalizeStyleRule( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', + }, + 'url(/home/projects/foo/assets/image.png)', + ), + ).toBe('url(griffel:assets/image.png)'); + }); + + it('handles rules with quotes', () => { + expect( + normalizeStyleRule( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', + }, + "url('/home/projects/foo/assets/image.png')", + ), + ).toBe('url(griffel:assets/image.png)'); + expect( + normalizeStyleRule( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', + }, + 'url("/home/projects/foo/assets/image.png")', + ), + ).toBe('url(griffel:assets/image.png)'); + }); + + it('keeps relative URLs', () => { + expect( + normalizeStyleRule( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', + }, + 'url(./foo.img)', + ), + ).toBe('url(./foo.img)'); + expect( + normalizeStyleRule( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', + }, + 'url(../foo.img)', + ), + ).toBe('url(../foo.img)'); + }); + + it('keeps data-url', () => { + expect( + normalizeStyleRule( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', + }, + 'url(data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2Q==)', + ), + ).toBe('url(data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2Q==)'); + }); + + it('handles Windows paths', () => { + expect( + normalizeStyleRule( + path.win32, + { + root: 'C:\\Users\\Foo\\projects\\bar', + filename: 'C:\\Users\\Foo\\projects\\bar\\src\\styles\\Component.styles.ts', + }, + 'url(C:\\Users\\Foo\\projects\\bar\\assets\\image.png)', + ), + ).toBe('url(griffel:assets/image.png)'); + }); +}); + +describe('normalizeStyleRules', () => { + it('handles rules without metadata', () => { + expect( + normalizeStyleRules( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', + }, + { + root: { + color: 'red', + backgroundImage: 'url(/home/projects/foo/assets/image.jpg)', + overflowY: ['hidden', 'scroll'], + + ':hover': { + backgroundImage: 'url(/home/projects/foo/assets/hoverImage.jpg)', + }, + + '@media screen and (max-width: 100px)': { + '& .foo': { + backgroundImage: 'url(/home/projects/foo/assets/mediaImage.jpg)', + }, + }, + }, + }, + ), + ).toEqual({ + root: { + color: 'red', + backgroundImage: 'url(griffel:assets/image.jpg)', + overflowY: ['hidden', 'scroll'], + + ':hover': { + backgroundImage: 'url(griffel:assets/hoverImage.jpg)', + }, + + '@media screen and (max-width: 100px)': { + '& .foo': { + backgroundImage: 'url(griffel:assets/mediaImage.jpg)', + }, + }, + }, + }); + }); + + it('handles multiple URLs', () => { + expect( + normalizeStyleRules( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', + }, + { + root: { + // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Backgrounds_and_Borders/Using_multiple_backgrounds + backgroundImage: [ + 'url(/home/projects/foo/assets/firefox.png),', + 'url(/home/projects/foo/assets/bubbles.png),', + 'linear-gradient(to right, rgba(30, 75, 115, 1), rgba(255, 255, 255, 0))', + ].join(' '), + }, + }, + ), + ).toEqual({ + root: { + backgroundImage: [ + 'url(griffel:assets/firefox.png),', + 'url(griffel:assets/bubbles.png),', + 'linear-gradient(to right, rgba(30, 75, 115, 1), rgba(255, 255, 255, 0))', + ].join(' '), + }, + }); + }); + + it('handles keyframe arrays', () => { + expect( + normalizeStyleRules( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', + }, + { + root: { + animationName: [{ from: { height: '20px' }, to: { height: '10px' } }], + }, + }, + ), + ).toEqual({ + root: { + animationName: [{ from: { height: '20px' }, to: { height: '10px' } }], + }, + }); + }); +}); diff --git a/packages/tag-processor/src/assets/normalizeStyleRules.ts b/packages/tag-processor/src/assets/normalizeStyleRules.ts new file mode 100644 index 000000000..f6bab71ea --- /dev/null +++ b/packages/tag-processor/src/assets/normalizeStyleRules.ts @@ -0,0 +1,76 @@ +import type { GriffelAnimation, GriffelResetStyle, GriffelStyle } from '@griffel/core'; +import { tokenize } from 'stylis'; + +import { FileContext } from '../types'; + +/** + * Linaria v4 emits absolute paths for assets, we normalize these paths to be relative from the project root to be the + * same if an assets was used in different files. + */ +export function normalizeAssetPath(path: typeof import('path'), fileContext: FileContext, absoluteAssetPath: string) { + // Normalize paths to be POSIX-like to be independent of an execution environment + return path.relative(fileContext.root, absoluteAssetPath).split(path.sep).join(path.posix.sep); +} + +export function normalizeStyleRule(path: typeof import('path'), fileContext: FileContext, ruleValue: string | number) { + if (typeof ruleValue === 'number' || ruleValue.indexOf('url(') === -1) { + return ruleValue; + } + + return tokenize(ruleValue).reduce((result, token, index, array) => { + if (token === 'url') { + const url = array[index + 1].slice(1, -1); + const isFilePath = url.replace(/^['|"]/, '').startsWith(fileContext.root); + + if (isFilePath) { + array[index + 1] = `(griffel:${normalizeAssetPath( + path, + fileContext, + // Quotes in URL are optional, so we can also normalize them as we know that it's a file path + // https://www.w3.org/TR/CSS2/syndata.html#value-def-uri + url.replace(/^['|"](.+)['|"]$/, '$1'), + )})`; + } else { + // Always replace with normalized value, so @griffel/core can de-duplicate them. + array[index + 1] = `(${url})`; + } + } + + return result + token; + }, ''); +} + +export function normalizeStyleRules( + path: typeof import('path'), + fileContext: FileContext, + stylesBySlots: Record | GriffelStyle | GriffelResetStyle, +): Record { + return Object.fromEntries( + Object.entries(stylesBySlots).map(([key, value]) => { + if (typeof value === 'undefined') { + return [key, value]; + } + + // Fallback values or keyframes + if (Array.isArray(value)) { + return [ + key, + value.map(rule => { + if (typeof rule === 'object') { + return normalizeStyleRules(path, fileContext, rule as GriffelAnimation); + } + + return normalizeStyleRule(path, fileContext, rule); + }), + ]; + } + + // Nested objects + if (typeof value === 'object') { + return [key, normalizeStyleRules(path, fileContext, value as unknown as GriffelStyle)]; + } + + return [key, normalizeStyleRule(path, fileContext, value)]; + }), + ); +} diff --git a/packages/tag-processor/src/assets/relativePathToImportLike.test.ts b/packages/tag-processor/src/assets/relativePathToImportLike.test.ts new file mode 100644 index 000000000..95f28d9c6 --- /dev/null +++ b/packages/tag-processor/src/assets/relativePathToImportLike.test.ts @@ -0,0 +1,74 @@ +import * as path from 'path'; +import { relativePathToImportLike } from './relativePathToImportLike'; + +describe('relativePathToImportLike', () => { + it('handles POSIX paths', () => { + expect( + relativePathToImportLike( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', + }, + 'assets/image.png', + ), + ).toBe('../../assets/image.png'); + + expect( + relativePathToImportLike( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/src/styles/Component.styles.ts', + }, + 'src/styles/Component.png', + ), + ).toBe('./Component.png'); + + expect( + relativePathToImportLike( + path.posix, + { + root: '/home/projects/foo', + filename: '/home/projects/foo/packages/components/src/index.styles.ts', + }, + 'packages/components/src/images/Component.png', + ), + ).toBe('./images/Component.png'); + }); + + it('handles Windows paths', () => { + expect( + relativePathToImportLike( + path.win32, + { + root: 'C:\\Users\\Foo\\projects', + filename: 'C:\\Users\\Foo\\projects\\src\\styles\\Component.styles.ts', + }, + 'assets/image.png', + ), + ).toBe('../../assets/image.png'); + + expect( + relativePathToImportLike( + path.win32, + { + root: 'C:\\Users\\Foo\\projects', + filename: 'C:\\Users\\Foo\\projects\\src\\styles\\Component.styles.ts', + }, + 'src/styles/Component.png', + ), + ).toBe('./Component.png'); + + expect( + relativePathToImportLike( + path.win32, + { + root: 'C:\\Users\\Foo\\projects', + filename: 'C:\\Users\\Foo\\projects\\packages\\components\\src\\index.styles.ts', + }, + 'packages/components/src/images/Component.png', + ), + ).toBe('./images/Component.png'); + }); +}); diff --git a/packages/tag-processor/src/assets/relativePathToImportLike.ts b/packages/tag-processor/src/assets/relativePathToImportLike.ts new file mode 100644 index 000000000..f8a946c76 --- /dev/null +++ b/packages/tag-processor/src/assets/relativePathToImportLike.ts @@ -0,0 +1,16 @@ +import type { FileContext } from '../types'; + +export function relativePathToImportLike(path: typeof import('path'), fileContext: FileContext, assetPath: string) { + const fileDirectory = path.dirname(fileContext.filename); + const absoluteAssetPath = path.resolve(fileContext.root, assetPath); + + let relativeAssetPath = path.relative(fileDirectory, absoluteAssetPath); + + if (!relativeAssetPath.startsWith('..' + path.sep)) { + relativeAssetPath = './' + relativeAssetPath; + } + + // Normalize paths to be POSIX-like as bundlers don't handle Windows paths + // "path.posix" does not make sense there as there is no "windows-to-posix-path" function + return relativeAssetPath.split(path.sep).join(path.posix.sep); +} diff --git a/packages/tag-processor/src/index.ts b/packages/tag-processor/src/index.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/tag-processor/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/tag-processor/src/types.ts b/packages/tag-processor/src/types.ts new file mode 100644 index 000000000..8d40090ad --- /dev/null +++ b/packages/tag-processor/src/types.ts @@ -0,0 +1,4 @@ +export type FileContext = { + root: string; + filename: string; +}; diff --git a/packages/tag-processor/src/utils/dedupeCSSRules.test.ts b/packages/tag-processor/src/utils/dedupeCSSRules.test.ts new file mode 100644 index 000000000..6ccb02622 --- /dev/null +++ b/packages/tag-processor/src/utils/dedupeCSSRules.test.ts @@ -0,0 +1,37 @@ +import type { CSSRulesByBucket } from '@griffel/core'; +import { dedupeCSSRules } from './dedupeCSSRules'; + +describe('dedupeCSSRules', () => { + it('dedupes simple rules', () => { + const rules: CSSRulesByBucket = { + d: ['.foo { color: red; }', '.foo { color: red; }', '.baz { color: pink }'], + }; + + expect(dedupeCSSRules(rules)).toMatchObject({ + d: ['.foo { color: red; }', '.baz { color: pink }'], + }); + }); + + it('dedupes rules with metadata', () => { + const rules: CSSRulesByBucket = { + m: [ + ['@media (min-width: 480px) { .foo { color: red; } }', { m: '(min-width: 480px)' }], + ['@media (min-width: 480px) { .baz { color: pink; } }', { m: '(min-width: 480px)' }], + + ['@media (min-width: 600px) { .foo { color: red; } }', { m: '(min-width: 600px)' }], + ['@media (min-width: 600px) { .foo { color: red; } }', { m: '(min-width: 600px)' }], + ['@media (min-width: 600px) { .baz { color: pink; } }', { m: '(min-width: 600px)' }], + ], + }; + + expect(dedupeCSSRules(rules)).toMatchObject({ + m: [ + ['@media (min-width: 480px) { .foo { color: red; } }', { m: '(min-width: 480px)' }], + ['@media (min-width: 480px) { .baz { color: pink; } }', { m: '(min-width: 480px)' }], + + ['@media (min-width: 600px) { .foo { color: red; } }', { m: '(min-width: 600px)' }], + ['@media (min-width: 600px) { .baz { color: pink; } }', { m: '(min-width: 600px)' }], + ], + }); + }); +}); diff --git a/packages/tag-processor/src/utils/dedupeCSSRules.ts b/packages/tag-processor/src/utils/dedupeCSSRules.ts new file mode 100644 index 000000000..16ca498da --- /dev/null +++ b/packages/tag-processor/src/utils/dedupeCSSRules.ts @@ -0,0 +1,22 @@ +import type { CSSRulesByBucket } from '@griffel/core'; + +/** + * Rules that are returned by `resolveStyles()` are not deduplicated. + * It's critical to filter out duplicates for build-time transform to avoid duplicated rules in a bundle. + */ +export function dedupeCSSRules(cssRulesByBucket: CSSRulesByBucket): CSSRulesByBucket { + return Object.fromEntries( + Object.entries(cssRulesByBucket).map(([styleBucketName, cssBucketEntries]) => { + if (styleBucketName === 'm') { + return [ + styleBucketName, + cssBucketEntries.filter( + (entryA, index, entries) => entries.findIndex(entryB => entryA[0] === entryB[0]) === index, + ), + ]; + } + + return [styleBucketName, [...new Set(cssBucketEntries)]]; + }), + ); +} diff --git a/packages/tag-processor/tsconfig.json b/packages/tag-processor/tsconfig.json new file mode 100644 index 000000000..42707b2da --- /dev/null +++ b/packages/tag-processor/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2019", "dom", "DOM.Iterable"], + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/tag-processor/tsconfig.lib.json b/packages/tag-processor/tsconfig.lib.json new file mode 100644 index 000000000..8d27d94f9 --- /dev/null +++ b/packages/tag-processor/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node", "environment"] + }, + "exclude": ["**/*.spec.ts", "**/*.test.ts"], + "include": ["**/*.ts"] +} diff --git a/packages/tag-processor/tsconfig.spec.json b/packages/tag-processor/tsconfig.spec.json new file mode 100644 index 000000000..167fe948f --- /dev/null +++ b/packages/tag-processor/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "jsx": "react", + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node", "environment"] + }, + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.tsx", + "**/*.spec.tsx", + "**/*.test.js", + "**/*.spec.js", + "**/*.test.jsx", + "**/*.spec.jsx", + "**/*.d.ts" + ] +} diff --git a/packages/webpack-loader/__fixtures__/webpack-inherit-resolve-options/color.jsx b/packages/webpack-loader/__fixtures__/webpack-inherit-resolve-options/color.jsx index cd0cebe93..7d549441c 100644 --- a/packages/webpack-loader/__fixtures__/webpack-inherit-resolve-options/color.jsx +++ b/packages/webpack-loader/__fixtures__/webpack-inherit-resolve-options/color.jsx @@ -1,3 +1,3 @@ const color = 'blue'; -export default color; \ No newline at end of file +export default color; diff --git a/tsconfig.base.json b/tsconfig.base.json index 3f9920744..6d2bb61fa 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,6 +24,9 @@ "@griffel/next-extraction-plugin": ["packages/next-extraction-plugin/src/index.ts"], "@griffel/postcss-syntax": ["packages/postcss-syntax/src/index.ts"], "@griffel/style-types": ["packages/style-types/src/index.ts"], + "@griffel/tag-processor": ["packages/tag-processor/src/index.ts"], + "@griffel/tag-processor/make-styles": ["packages/tag-processor/src/MakeStylesProcessor.ts"], + "@griffel/tag-processor/make-reset-styles": ["packages/tag-processor/src/MakeResetStylesProcessor.ts"], "@griffel/react": ["packages/react/src/index.ts"], "@griffel/webpack-extraction-plugin": ["packages/webpack-extraction-plugin/src/index.ts"], "@griffel/webpack-loader": ["packages/webpack-loader/src/index.ts"] diff --git a/workspace.json b/workspace.json index d8ebbb4ff..63b7bc5c6 100644 --- a/workspace.json +++ b/workspace.json @@ -14,6 +14,7 @@ "@griffel/postcss-syntax": "packages/postcss-syntax", "@griffel/react": "packages/react", "@griffel/style-types": "packages/style-types", + "@griffel/tag-processor": "packages/tag-processor", "@griffel/webpack-extraction-plugin": "packages/webpack-extraction-plugin", "@griffel/webpack-loader": "packages/webpack-loader", "@griffel/website": "apps/website" diff --git a/yarn.lock b/yarn.lock index e03aeec5a..a276a76fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -246,15 +246,15 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:>=7, @babel/generator@npm:^7.12.11, @babel/generator@npm:^7.12.5, @babel/generator@npm:^7.17.9, @babel/generator@npm:^7.18.7, @babel/generator@npm:^7.23.0, @babel/generator@npm:^7.7.2": - version: 7.23.0 - resolution: "@babel/generator@npm:7.23.0" +"@babel/generator@npm:>=7, @babel/generator@npm:^7.12.11, @babel/generator@npm:^7.12.5, @babel/generator@npm:^7.17.9, @babel/generator@npm:^7.18.7, @babel/generator@npm:^7.23.0, @babel/generator@npm:^7.23.5, @babel/generator@npm:^7.7.2": + version: 7.23.5 + resolution: "@babel/generator@npm:7.23.5" dependencies: - "@babel/types": "npm:^7.23.0" + "@babel/types": "npm:^7.23.5" "@jridgewell/gen-mapping": "npm:^0.3.2" "@jridgewell/trace-mapping": "npm:^0.3.17" jsesc: "npm:^2.5.1" - checksum: bd1598bd356756065d90ce26968dd464ac2b915c67623f6f071fb487da5f9eb454031a380e20e7c9a7ce5c4a49d23be6cb9efde404952b0b3f3c0c3a9b73d68a + checksum: 094af79c2e8fdb0cfd06b42ff6a39a8a95639bc987cace44f52ed5c46127f5469eb20ab5f4c8991fc00fa9c1445a1977cde8e44289d6be29ddbb315fb0fc1b45 languageName: node linkType: hard @@ -505,10 +505,10 @@ __metadata: languageName: node linkType: hard -"@babel/helper-string-parser@npm:^7.22.5": - version: 7.22.5 - resolution: "@babel/helper-string-parser@npm:7.22.5" - checksum: 7f275a7f1a9504da06afc33441e219796352a4a3d0288a961bc14d1e30e06833a71621b33c3e60ee3ac1ff3c502d55e392bcbc0665f6f9d2629809696fab7cdd +"@babel/helper-string-parser@npm:^7.23.4": + version: 7.23.4 + resolution: "@babel/helper-string-parser@npm:7.23.4" + checksum: c352082474a2ee1d2b812bd116a56b2e8b38065df9678a32a535f151ec6f58e54633cc778778374f10544b930703cca6ddf998803888a636afa27e2658068a9c languageName: node linkType: hard @@ -1906,14 +1906,14 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.12.11, @babel/types@npm:^7.12.7, @babel/types@npm:^7.15.6, @babel/types@npm:^7.2.0, @babel/types@npm:^7.22.15, @babel/types@npm:^7.22.19, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3": - version: 7.23.0 - resolution: "@babel/types@npm:7.23.0" +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.12.11, @babel/types@npm:^7.12.7, @babel/types@npm:^7.15.6, @babel/types@npm:^7.2.0, @babel/types@npm:^7.22.15, @babel/types@npm:^7.22.19, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.5, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3": + version: 7.23.5 + resolution: "@babel/types@npm:7.23.5" dependencies: - "@babel/helper-string-parser": "npm:^7.22.5" + "@babel/helper-string-parser": "npm:^7.23.4" "@babel/helper-validator-identifier": "npm:^7.22.20" to-fast-properties: "npm:^2.0.0" - checksum: ca5b896a26c91c5672254725c4c892a35567d2122afc47bd5331d1611a7f9230c19fc9ef591a5a6f80bf0d80737e104a9ac205c96447c74bee01d4319db58001 + checksum: a623a4e7f396f1903659099da25bfa059694a49f42820f6b5288347f1646f0b37fb7cc550ba45644e9067149368ef34ccb1bd4a4251ec59b83b3f7765088f363 languageName: node linkType: hard @@ -7682,6 +7682,27 @@ __metadata: languageName: node linkType: hard +"@wyw-in-js/processor-utils@npm:^0.2.2": + version: 0.2.2 + resolution: "@wyw-in-js/processor-utils@npm:0.2.2" + dependencies: + "@babel/generator": "npm:^7.23.5" + "@wyw-in-js/shared": "npm:0.2.2" + checksum: 7f2a2c3ec139b93bacc75c32d09ca482fe208b90ad5a5e36443693e3ce820296cf00329d3f43928471bff913cd6570febc0f44f5c1ba704a020f76554a64e57e + languageName: node + linkType: hard + +"@wyw-in-js/shared@npm:0.2.2": + version: 0.2.2 + resolution: "@wyw-in-js/shared@npm:0.2.2" + dependencies: + debug: "npm:^4.3.4" + find-up: "npm:^5.0.0" + minimatch: "npm:^9.0.3" + checksum: 309749ab7db00a74c426b9a8320c9b94fac45bb1f57ac8db7ebb13411df6788cf04c59b540a230132bdb587694d33aa8e3ad2d3edc1fac405fc3d4f08674092c + languageName: node + linkType: hard + "@xtuc/ieee754@npm:^1.2.0": version: 1.2.0 resolution: "@xtuc/ieee754@npm:1.2.0" @@ -14872,6 +14893,7 @@ __metadata: "@typescript-eslint/parser": "npm:5.47.0" "@typescript-eslint/utils": "npm:^5.47.0" "@uifabric/merge-styles": "npm:7.19.1" + "@wyw-in-js/processor-utils": "npm:^0.2.2" ajv: "npm:^8.4.0" babel-jest: "npm:28.1.3" babel-loader: "npm:8.1.0" @@ -19210,6 +19232,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^9.0.3": + version: 9.0.3 + resolution: "minimatch@npm:9.0.3" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: c81b47d28153e77521877649f4bab48348d10938df9e8147a58111fe00ef89559a2938de9f6632910c4f7bf7bb5cd81191a546167e58d357f0cfb1e18cecc1c5 + languageName: node + linkType: hard + "minimist-options@npm:4.1.0": version: 4.1.0 resolution: "minimist-options@npm:4.1.0"