Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ document.write(`

- [Setup](#setup)
- [webpack](#webpack)
- [esbuild](#esbuild)
- [Gatsby](#gatsby)
- [API](#api)
- [style](#style)
Expand All @@ -101,7 +102,7 @@ document.write(`

## Setup

There are currently a couple of integrations to choose from.
There are currently a few integrations to choose from.

### webpack

Expand Down Expand Up @@ -159,6 +160,32 @@ module.exports = {
```
</details>

### esbuild

Current limitations:

- No automatic readable class names during development. However, you can still manually provide a debug ID as the last argument to functions that generate scoped styles, e.g. `export const className = style({ ... }, 'className');`
- The `projectRoot` plugin option must be set to get deterministic class name hashes between build systems

1. Install the dependencies.

```bash
$ yarn add --dev @vanilla-extract/css @vanilla-extract/esbuild-plugin
```

2. Add the [esbuild](https://esbuild.github.io/) plugin to your build script.

```js
const { vanillaExtractPlugin } = require('@vanilla-extract/esbuild-plugin');

require('esbuild').build({
entryPoints: ['app.ts'],
bundle: true,
plugins: [vanillaExtractPlugin({ projectRoot: '...' })],
outfile: 'out.js',
}).catch(() => process.exit(1))
```

### Gatsby

To add to your [Gatsby](https://www.gatsbyjs.com) site, use the [gatsby-plugin-vanilla-extract](https://github.com/KyleAMathews/gatsby-plugin-vanilla-extract) plugin.
Expand Down
32 changes: 32 additions & 0 deletions packages/esbuild-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@vanilla-extract/esbuild-plugin",
"version": "0.0.1",
"description": "Zero-runtime Stylesheets-in-TypeScript",
"main": "dist/vanilla-extract-esbuild-plugin.cjs.js",
"module": "dist/vanilla-extract-esbuild-plugin.esm.js",
"files": [
"/dist"
],
"repository": {
"type": "git",
"url": "https://github.com/seek-oss/vanilla-extract.git",
"directory": "packages/esbuild-plugin"
},
"author": "SEEK",
"license": "MIT",
"peerDependencies": {
"esbuild": ">=0.11.1"
},
"dependencies": {
"@vanilla-extract/css": "^0.1.0",
"chalk": "^4.1.0",
"dedent": "^0.7.0",
"eval": "^0.1.6",
"javascript-stringify": "^2.0.1",
"lodash": "^4.17.21"
},
"devDependencies": {
"@types/dedent": "^0.7.0",
"esbuild": "^0.11.1"
}
}
214 changes: 214 additions & 0 deletions packages/esbuild-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { dirname, relative } from 'path';
import { promises as fs } from 'fs';

import type { Adapter } from '@vanilla-extract/css';
import { setAdapter } from '@vanilla-extract/css/adapter';
import { transformCss } from '@vanilla-extract/css/transformCss';
import dedent from 'dedent';
import { build as esbuild, Plugin } from 'esbuild';
// @ts-expect-error
import evalCode from 'eval';
import { stringify } from 'javascript-stringify';
import isPlainObject from 'lodash/isPlainObject';

const vanillaExtractPath = dirname(
require.resolve('@vanilla-extract/css/package.json'),
);

const vanillaCssNamespace = 'vanilla-extract-css-ns';

interface FilescopePluginOptions {
projectRoot?: string;
}
const vanillaExtractFilescopePlugin = ({
projectRoot,
}: FilescopePluginOptions): Plugin => ({
name: 'vanilla-extract-filescope',
setup(build) {
build.onLoad({ filter: /\.(js|jsx|ts|tsx)$/ }, async ({ path }) => {
const originalSource = await fs.readFile(path, 'utf-8');

if (
path.indexOf(vanillaExtractPath) === -1 &&
originalSource.indexOf('@vanilla-extract/css/fileScope') === -1
) {
const fileScope = projectRoot ? relative(projectRoot, path) : path;

const contents = `
import { setFileScope, endFileScope } from "@vanilla-extract/css/fileScope";
setFileScope("${fileScope}");
${originalSource}
endFileScope()
`;

return {
contents,
resolveDir: dirname(path),
};
}
});
},
});

interface VanillaExtractPluginOptions {
outputCss?: boolean;
externals?: Array<string>;
projectRoot?: string;
runtime?: boolean;
}
export function vanillaExtractPlugin({
outputCss = true,
externals = [],
projectRoot,
runtime = false,
}: VanillaExtractPluginOptions = {}): Plugin {
if (runtime) {
// If using runtime CSS then just apply fileScopes to code
return vanillaExtractFilescopePlugin({ projectRoot });
}

return {
name: 'vanilla-extract',
setup(build) {
build.onResolve({ filter: /vanilla\.css\?source=.*$/ }, (args) => {
return {
path: args.path,
namespace: vanillaCssNamespace,
};
});

build.onLoad(
{ filter: /.*/, namespace: vanillaCssNamespace },
({ path }) => {
const [, source] = path.match(/\?source=(.*)$/) ?? [];

if (!source) {
throw new Error('No source in vanilla CSS file');
}

return {
contents: Buffer.from(source, 'base64').toString('utf-8'),
loader: 'css',
};
},
);

build.onLoad({ filter: /\.css\.(js|jsx|ts|tsx)$/ }, async ({ path }) => {
const result = await esbuild({
entryPoints: [path],
metafile: true,
bundle: true,
external: ['@vanilla-extract', ...externals],
platform: 'node',
write: false,
plugins: [vanillaExtractFilescopePlugin({ projectRoot })],
});

const { outputFiles } = result;

if (!outputFiles || outputFiles.length !== 1) {
throw new Error('Invalid child compilation');
}

type Css = Parameters<Adapter['appendCss']>[0];
const cssByFileScope = new Map<string, Array<Css>>();
const localClassNames = new Set<string>();

const cssAdapter: Adapter = {
appendCss: (css, fileScope) => {
if (outputCss) {
const fileScopeCss = cssByFileScope.get(fileScope) ?? [];

fileScopeCss.push(css);

cssByFileScope.set(fileScope, fileScopeCss);
}
},
registerClassName: (className) => {
localClassNames.add(className);
},
onEndFileScope: () => {},
};

setAdapter(cssAdapter);

const sourceWithBoundLoaderInstance = `require('@vanilla-extract/css/adapter').setAdapter(__adapter__);${outputFiles[0].text}`;

const evalResult = evalCode(
sourceWithBoundLoaderInstance,
path,
{ console, __adapter__: cssAdapter },
true,
);

const cssRequests = [];

for (const [fileScope, fileScopeCss] of cssByFileScope) {
const css = transformCss({
localClassNames: Array.from(localClassNames),
cssObjs: fileScopeCss,
}).join('\n');
const base64Css = Buffer.from(css, 'utf-8').toString('base64');

cssRequests.push(`${fileScope}.vanilla.css?source=${base64Css}`);
}

const contents = serializeVanillaModule(cssRequests, evalResult);

return {
contents,
loader: 'js',
};
});
},
};
}

const stringifyExports = (value: any) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More just marking this for future conversation, but this makes me think it might be good to have a more first-class integration API so stuff like this is handled for you.

stringify(
value,
(value, _indent, next) => {
const valueType = typeof value;
if (
valueType === 'string' ||
valueType === 'number' ||
valueType === 'undefined' ||
value === null ||
Array.isArray(value) ||
isPlainObject(value)
) {
return next(value);
}

throw new Error(dedent`
Invalid exports.

You can only export plain objects, arrays, strings, numbers and null/undefined.
`);
},
0,
{
references: true, // Allow circular references
maxDepth: Infinity,
maxValues: Infinity,
},
);

const serializeVanillaModule = (
cssRequests: Array<string>,
exports: Record<string, unknown>,
) => {
const cssImports = cssRequests.map((request) => {
return `import '${request}';`;
});

const moduleExports = Object.keys(exports).map((key) =>
key === 'default'
? `export default ${stringifyExports(exports[key])};`
: `export var ${key} = ${stringifyExports(exports[key])};`,
);

const outputCode = [...cssImports, ...moduleExports];

return outputCode.join('\n');
};
2 changes: 1 addition & 1 deletion test-helpers/src/getStylesheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import got from 'got';

export const stylesheetName = 'main.css';

export async function getStylesheet(url: string) {
export async function getStylesheet(url: string, stylesheetName = 'main.css') {
const response = await got(`${url}/${stylesheetName}`);

return response.body;
Expand Down
3 changes: 3 additions & 0 deletions test-helpers/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export * from './startFixture';
export * from './getNodeStyles';
export * from './getStylesheet';

export const getTestNodes = (fixture: string) =>
require(`@fixtures/${fixture}/test-nodes.json`);
77 changes: 77 additions & 0 deletions test-helpers/src/startFixture/esbuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import path from 'path';
import { existsSync, promises as fs } from 'fs';

import { vanillaExtractPlugin } from '@vanilla-extract/esbuild-plugin';
import { serve } from 'esbuild';

import { TestServer } from './types';

export interface EsbuildFixtureOptions {
type: 'esbuild' | 'esbuild-runtime';
mode?: 'development' | 'production';
port: number;
}
export const startEsbuildFixture = async (
fixtureName: string,
{ type, mode = 'development', port = 3000 }: EsbuildFixtureOptions,
): Promise<TestServer> => {
const entry = require.resolve(`@fixtures/${fixtureName}`);
const projectRoot = path.dirname(
require.resolve(`@fixtures/${fixtureName}/package.json`),
);
const outdir = path.join(projectRoot, 'dist');

if (existsSync(outdir)) {
await fs.rm(outdir, { recursive: true });
}

await fs.mkdir(outdir);

const server = await serve(
{ servedir: outdir, port },
{
entryPoints: [entry],
metafile: true,
platform: 'browser',
bundle: true,
minify: mode === 'production',
plugins: [
vanillaExtractPlugin({
projectRoot,
runtime: type === 'esbuild-runtime',
}),
],
outdir,
define: {
'process.env.NODE_ENV': JSON.stringify(mode),
},
},
);

await fs.writeFile(
path.join(outdir, 'index.html'),
`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>esbuild - ${fixtureName}</title>
<link rel="stylesheet" type="text/css" href="index.css" />
</head>
<body>
<script src="index.js"></script>
</body>
</html>
`,
);

return {
type: 'esbuild',
url: `http://localhost:${port}`,
close: () => {
server.stop();

return Promise.resolve();
},
};
};
Loading