Skip to content

Commit

Permalink
feat: Add customProperties config to postcss-lwc-plugin (#349)
Browse files Browse the repository at this point in the history
## Details

This PR introduces `customProperties` config to the `postcss-lwc-plugin`. This new API would allow the compiler to do the inline transformation of the CSS custom properties on compat browsers (eg. IE11).

## Does this PR introduce a breaking change?

* [ ] Yes
* [X] No
  • Loading branch information
pmdartus authored Jun 12, 2018
1 parent 598d940 commit 231e00d
Show file tree
Hide file tree
Showing 14 changed files with 346 additions and 24 deletions.
12 changes: 0 additions & 12 deletions packages/postcss-plugin-lwc/CHANGELOG.md

This file was deleted.

92 changes: 88 additions & 4 deletions packages/postcss-plugin-lwc/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
# postcss-plugin-lwc

[Postcss](https://github.com/postcss/postcss) plugin to parse and add scoping to CSS rules for Raptor components.
[Postcss](https://github.com/postcss/postcss) plugin for LWC components styles.

## Features

* Selector scoping to respect Shadow DOM style encapsulation
* Transform `:host` and `:host-context` pseudo-class selectors
* Selectors
* Scoping CSS selectors to enforce Shadow DOM style encapsulation
* Transform `:host` and `:host-context` pseudo-class selectors
* Custom Properties
* Inline replacement of `var()` CSS function

## Installation

Expand Down Expand Up @@ -62,8 +65,89 @@ Required: `true`

A unique token to scope the CSS rules. The rules will apply only to element having the token as attribute.

### `customProperties`

## Caveats
Type: `object`
Required: `false`

#### `customProperties.allowDefinition`

Type: `boolean`
Required: `false`
Default: `true`

When `false` the plugin will throw an error if a custom property is defined in the stylesheet.

```js
lwcPlugin({
// ... other options
customProperties: {
allowDefinition: false
}
});
```

```css
:host {
--bg-color: red;
/* ^ PostCSS Error - Invalid custom property definition for "--bg-color" */
}
```

#### `customProperties.transformVar`

Type: `(name: string, fallback?: string): string`
Required: `false`
Default: `undefined`

Hook that allows to replace `var()` function usage in the stylesheet. The `transformVar` function receives a custom property name and a fallback value, to be used when custom property does not exist. The resulting string is then inserted into generated stylesheet.

```js
lwcPlugin({
// ... other options
customProperties: {
transformVar(name, fallback) {
if (name === '--lwc-bg') {
return 'red';
} else {
return fallback;
}
}
}
});
```

```css
div {
background-color: var(--lwc-bg);
color: var(--lwc-color, purple);
}

/* becomes */

div {
background-color: red;
color: purple;
}
```

## Attribute usage restrictions

Since LWC uses the HTML attribute syntax to define properties on components, it will be misleading to use attribute selectors when styling a component. For this reason the CSS transform restricts the usage of CSS attribute selectors.

* CSS selectors using [Global HTML attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes) are allowed.
* Usage of attributes are only allowed in compound selectors with known tag selectors

```css
[hidden] {} /* ✅ OK - global HTML attribute selector */
x-btn[hidden] {} /* ✅ OK - global HTML attribute selector */

[min=0] {} /* 🚨 ERROR - the compound selector is not specific enough */
input[min=0] {} /* ✅ OK - "min" attribute is a known special attribute on the "input" element */
x-btn[min=0] {} /* 🚨 ERROR - invalid usage "min" attribute on "x-btn" */
```

## Selector scoping caveats

* No support for [`::slotted`](https://drafts.csswg.org/css-scoping/#slotted-pseudo) pseudo-element.
* No support for [`>>>`](https://drafts.csswg.org/css-scoping/#deep-combinator) deep combinator (spec still under consideration: [issue](https://github.com/w3c/webcomponents/issues/78)).
Expand Down
2 changes: 1 addition & 1 deletion packages/postcss-plugin-lwc/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "postcss-plugin-lwc",
"version": "0.22.8",
"description": "Postcss plugin for LWC style scoping",
"description": "Postcss plugin for LWC components styles",
"main": "dist/commonjs/index.js",
"types": "dist/types/index.d.ts",
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { process } from './shared';

describe('var transform', () => {
it('should handle single variable in declaration value', async () => {
const { css } = await process('div { color: var(--lwc-color); }');

expect(css).toBe(
'div[x-foo_tmpl] { color: $VAR(--lwc-color)$; }',
);
});

it('should handle default value', async () => {
const { css } = await process('div { color: var(--lwc-color, black); }');

expect(css).toBe(
'div[x-foo_tmpl] { color: $VAR(--lwc-color, black)$; }',
);
});

it('should handle variables with tails', async () => {
const { css } = await process(
'div { color: var(--lwc-color) important; }',
);

expect(css).toBe(
'div[x-foo_tmpl] { color: $VAR(--lwc-color)$ important; }',
);
});

it('should handle multiple variables in a single declaration value', async () => {
const { css } = await process(
'div { color: var(--lwc-color), var(--lwc-other); }',
);

expect(css).toBe(
'div[x-foo_tmpl] { color: $VAR(--lwc-color)$, $VAR(--lwc-other)$; }',
);
});

it('should handle function in default value', async () => {
const { css } = await process(
'div { border: var(--border, 1px solid rgba(0, 0, 0, 0.1)); }',
);

expect(css).toBe(
'div[x-foo_tmpl] { border: $VAR(--border, 1px solid rgba(0, 0, 0, 0.1))$; }',
);
});

it('should handle multiple variable in a function', async () => {
const { css } = await process(
'div { background: linear-gradient(to top, var(--lwc-color), var(--lwc-other)); }',
);

expect(css).toBe(
'div[x-foo_tmpl] { background: linear-gradient(to top, $VAR(--lwc-color)$, $VAR(--lwc-other)$); }',
);
});

it('should handle nested var', async () => {
const { css } = await process(
'div { background: var(--lwc-color, var(--lwc-other, black)); }',
);

expect(css).toBe(
'div[x-foo_tmpl] { background: $VAR(--lwc-color, $VAR(--lwc-other, black)$)$; }',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { process, FILE_NAME, DEFAULT_TAGNAME, DEFAULT_TOKEN } from './shared';

const NO_CUSTOM_PROPERTY_CONFIG = {
tagName: DEFAULT_TAGNAME,
token: DEFAULT_TOKEN,
customProperties: {
allowDefinition: false,
},
};

it('should prevent definition of standard custom properties', () => {
return expect(
process('div { --bg-color: blue; }', NO_CUSTOM_PROPERTY_CONFIG),
).rejects.toMatchObject({
message: expect.stringContaining(
`Invalid definition of custom property "--bg-color"`,
),
file: FILE_NAME,
line: 1,
column: 7,
});
});

it('should prevent definition of lwc-prefixed custom properties', () => {
return expect(
process('div { --lwc-bg-color: blue; }', NO_CUSTOM_PROPERTY_CONFIG),
).rejects.toMatchObject({
message: expect.stringContaining(
`Invalid definition of custom property "--lwc-bg-color"`,
),
file: FILE_NAME,
line: 1,
column: 7,
});
});
15 changes: 14 additions & 1 deletion packages/postcss-plugin-lwc/src/__tests__/shared.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import * as postcss from 'postcss';

import lwcPlugin from '../index';
import { PluginConfig } from '../config';

export const FILE_NAME = '/test.css';

export const DEFAULT_TAGNAME = 'x-foo';
export const DEFAULT_TOKEN = 'x-foo_tmpl';
export const DEFAULT_CUSTOM_PROPERTIES_CONFIG = {
allowDefinition: false,
transformVar: (name: string, fallback: string | undefined) => {
return fallback === undefined ? `$VAR(${name})$` : `$VAR(${name}, ${fallback})$`;
},
};

export function process(
source: string,
options: any = { tagName: DEFAULT_TAGNAME, token: DEFAULT_TOKEN },
options: PluginConfig = {
tagName: DEFAULT_TAGNAME,
token: DEFAULT_TOKEN,
customProperties: DEFAULT_CUSTOM_PROPERTIES_CONFIG,
},
) {
const plugins = [lwcPlugin(options)];
return postcss(plugins).process(source, { from: FILE_NAME });
Expand Down
6 changes: 6 additions & 0 deletions packages/postcss-plugin-lwc/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
export type VarTransformer = (name: string, fallback: string) => string;

export interface PluginConfig {
tagName: string;
token: string;
customProperties?: {
allowDefinition?: boolean;
transformVar?: VarTransformer;
};
}

export function validateConfig(options: PluginConfig) {
Expand Down
95 changes: 95 additions & 0 deletions packages/postcss-plugin-lwc/src/custom-properties/transform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Declaration } from 'postcss';

import { VarTransformer } from '../config';

// Match on " var("
const VAR_FUNC_REGEX = /(^|[^\w-])var\(/;

// Match on "<property-name>" and "<property-name>, <fallback-value>"
const VAR_ARGUMENTS_REGEX = /[\f\n\r\t ]*([\w-]+)(?:[\f\n\r\t ]*,[\f\n\r\t ]*([\W\w]+))?/;

/**
* Returns the index of the matching closing parenthesis. If no matching parenthesis is found
* the method returns -1.
*/
function indexOfMatchingParenthesis(value: string, start: number): number {
let i = start;

// Counter keeping track of the function call nesting count.
let nesting = 0;

while (i < value.length) {
const ch = value.charAt(i);

// When the function arguments contains an open parenthesis, it means that the function
// arguments contains nested function calls.
// For example: `var(--min-width, calc(100% - 80px));`
if (ch === '(') {
nesting += 1;
}

if (ch === ')') {
if (nesting === 0) {
return i;
} else {
nesting -= 1;
}
}

i += 1;
}

// Handle case where no matching closing parenthesis has been found.
return -1;
}

function transform(decl: Declaration, transformer: VarTransformer, value: string): string {
const varMatch = VAR_FUNC_REGEX.exec(value);

// Early exit of the value doesn't contain any `var` function call
if (varMatch === null) {
return value;
}

const [, prefix] = varMatch;

// Extract start and end location of the function call
const varStart = varMatch.index;
const varEnd = indexOfMatchingParenthesis(value, varStart + varMatch[0].length);

if (varEnd === -1) {
throw decl.error(
`Missing closing ")" for "${value.slice(varStart)}"`
);
}

// Extract function call arguments
const varFunction = value.slice(varStart, varEnd + 1);
const varArguments = value.slice(varStart + varMatch[0].length, varEnd);
const varArgumentsMatch = VAR_ARGUMENTS_REGEX.exec(varArguments);

if (varArgumentsMatch === null) {
throw decl.error(
`Invalid var function signature for "${varFunction}"`
);
}

const [, name, fallback] = varArgumentsMatch;
const transformationResult = transformer(name, fallback);

if (typeof transformationResult !== 'string') {
throw new TypeError(`Expected a string, but received instead "${typeof transformationResult}"`);
}

// Recursively calling transform to processed the remaining `var` function calls.
const processed = value.slice(0, varStart);
const toProcess = transformationResult + value.slice(varEnd + 1);
const tail = transform(decl, transformer, toProcess);

return processed + prefix + tail;
}

export default function(decl: Declaration, transformer: VarTransformer) {
const { value } = decl;
decl.value = transform(decl, transformer, value);
}
13 changes: 13 additions & 0 deletions packages/postcss-plugin-lwc/src/custom-properties/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Declaration } from 'postcss';

const CUSTOM_PROPERTY_IDENTIFIER = '--';

export default function validate(decl: Declaration): void {
const { prop } = decl;

if (prop.startsWith(CUSTOM_PROPERTY_IDENTIFIER)) {
throw decl.error(
`Invalid definition of custom property "${prop}".`,
);
}
}
Loading

0 comments on commit 231e00d

Please sign in to comment.