Skip to content

Commit

Permalink
feat(typescript-transform-lit-css): add typescript-transform-lit-css
Browse files Browse the repository at this point in the history
  • Loading branch information
bennypowers committed Oct 28, 2023
1 parent aef6238 commit ac7f745
Show file tree
Hide file tree
Showing 6 changed files with 341 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/esbuild-plugin-lit-css/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,4 @@ await esbuild.build({

Looking for webpack? [lit-css-loader](../lit-css-loader)
Looking for rollup? [rollup-plugin-lit-css](../rollup-plugin-lit-css)
Looking for typescript? [typescript-transform-lit-css](../typescript-transform-lit-css)
1 change: 1 addition & 0 deletions packages/lit-css-loader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,4 @@ module.exports = {

Looking for rollup? [rollup-plugin-lit-css](../rollup-plugin-lit-css)
Looking for esbuild? [esbuild-plugin-lit-css](../esbuild-plugin-lit-css)
Looking for typescript? [typescript-transform-lit-css](../typescript-transform-lit-css)
4 changes: 4 additions & 0 deletions packages/rollup-plugin-lit-css/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,7 @@ export default {
]
}
```

Looking for webpack? [lit-css-loader](../lit-css-loader)
Looking for esbuild? [esbuild-plugin-lit-css](../esbuild-plugin-lit-css)
Looking for typescript? [typescript-transform-lit-css](../typescript-transform-lit-css)
106 changes: 106 additions & 0 deletions packages/typescript-transform-lit-css/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# typescript-transform-lit-css

TypeScript transformer to import css files as JavaScript tagged-template literal objects.
Use it with [ts-patch](https://npm.im/ts-patch)

> _The "Lit" stands for "Literal"_
You can use it to import CSS for various libraries like `lit-element`, `@microsoft/fast-element`, or others.

## Do I Need This?

No. This is an optional package who's sole purpose is to make it easier to write CSS-in-CSS while working on lit-element projects. You can just as easily write your CSS in some '`styles.css.js`' modules a la:

```js
import { css } from 'lit-element';
export default css`:host { display: block; }`;
```

And this may actually be preferred.

Hopefully this package will become quickly obsolete when the [CSS Modules Proposal](https://github.com/w3c/webcomponents/issues/759) (or something like it) is accepted and implemented.

In the mean time, enjoy importing your CSS into your component files.

## Options

| Name | Accepts | Default |
| ----------- | -------------------------------------------------------------------------------------- | ----------- |
| `filter` | RegExp of file names to apply to | `/\.css$/i` |
| `uglify` | Boolean or Object of [uglifycss](https://www.npmjs.com/package/uglifycss#api) options. | `false` |
| `specifier` | Package to import `css` from | `lit` |
| `tag` | Name of the template-tag function | `css` |
| `transform` | Optional function (sync or async) which transforms css sources (e.g. postcss) | `x => x` |

## Usage

```json5
{
"compilerOptions": {
"plugins": [
{
"transform": "typescript-transform-lit-css",
},
]
}
}
```

Then import your CSS:

```css
:host {
display: block;
}

h1 {
color: hotpink;
}
```

```ts
import { LitElement, customElement, html } from 'lit-element';

import style from './css-in-css.css';

@customElement('css-in-css')
class CSSInCSS extends LitElement {
static get styles() {
return [style];
}

render() {
return html`<h1>It's Lit!</h1>`;
}
}
```

### Usage with FAST

```json5
{
"compilerOptions": {
"plugins": [
{
"transform": "typescript-transform-lit-css",
"specifier": "@microsoft/fast-element",
},
]
}
}
```

```ts
import { FASTElement, customElement, html } from '@microsoft/fast-element';

import styles from './css-in-css.css';

const template = html<CSSinCSS>`<h1>It's Lit!</h1>`;

@customElement({ name: 'css-in-css', template, styles })
class CSSinCSS extends FASTElement {}
```

Looking for esbuild? [esbuild-plugin-lit-css](../esbuild-plugin-lit-css)
Looking for webpack? [lit-css-loader](../lit-css-loader)
Looking for rollup? [rollup-plugin-lit-css](../rollup-plugin-lit-css)
42 changes: 42 additions & 0 deletions packages/typescript-transform-lit-css/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "typescript-transform-lit-css",
"description": "import CSS files as tagged template literals",
"version": "1.0.0",
"type": "module",
"main": "typescript-transform-lit-css.js",
"types": "typescript-transform-lit-css.d.ts",
"exports": {
"import": "./typescript-transform-lit-css.js",
"require": "./typescript-transform-lit-css.cjs"
},
"author": "Benny Powers <[email protected]>",
"license": "ISC",
"repository": {
"type": "git",
"url": "git+ssh://[email protected]/bennypowers/lit-css.git",
"directory": "packages/typescript-transform-lit-css"
},
"bugs": {
"url": "https://github.com/bennypowers/lit-css/issues"
},
"keywords": [
"typescript",
"transform",
"lit",
"css",
"webcomponents"
],
"files": [
"typescript-transform-lit-css.cjs",
"typescript-transform-lit-css.js",
"typescript-transform-lit-css.d.ts"
],
"dependencies": {
"@pwrs/lit-css": "^2.0.0"
},
"peerDependencies": {
"typescript": "^5",
"ts-patch": "^3.0",
"lit": "^2.7.2 || ^3.0.0"
}
}
187 changes: 187 additions & 0 deletions packages/typescript-transform-lit-css/typescript-transform-lit-css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import type { Options } from '@pwrs/lit-css/lit-css';
import type { TransformerExtras, PluginConfig } from 'ts-patch';

import fs from 'node:fs';
import { URL, pathToFileURL } from 'node:url';

import CleanCSS from 'clean-css';

import ts, { ImportDeclaration, SourceFile } from 'typescript';

export interface LitCSSOptions extends Pick<Options, 'specifier'|'filePath'|'tag'> {
filter: string;
uglify: boolean;
inline: boolean;
}

const SEEN_SOURCES = new WeakSet();

function createLitCssImportStatement(
ctx: ts.CoreTransformationContext,
sourceFile: ts.SourceFile,
specifier: string,
tag: string,
) {
if (SEEN_SOURCES.has(sourceFile))
return;

// eslint-disable-next-line easy-loops/easy-loops
for (const statement of sourceFile.statements) {
if (
ts.isImportDeclaration(statement) &&
statement.moduleSpecifier.getText() === specifier) {
// eslint-disable-next-line easy-loops/easy-loops
for (const binding of statement.importClause?.namedBindings?.getChildren() ?? []) {
if (binding.getText() === tag) {
SEEN_SOURCES.add(sourceFile);
return;
}
}
}
}

SEEN_SOURCES.add(sourceFile);

return ctx.factory.createImportDeclaration(
undefined,
ctx.factory.createImportClause(
false,
undefined,
ctx.factory.createNamedImports([
ctx.factory.createImportSpecifier(
false,
undefined,
ctx.factory.createIdentifier('css')
),
]),
),
ctx.factory.createStringLiteral(specifier),
);
}

function createLitCssTaggedTemplateLiteral(
ctx: ts.CoreTransformationContext,
stylesheet: string,
name: string,
tag: string,
) {
return ctx.factory.createVariableStatement(
undefined,
ctx.factory.createVariableDeclarationList([
ctx.factory.createVariableDeclaration(
name ?? 'style',
undefined,
undefined,
ctx.factory.createTaggedTemplateExpression(
ctx.factory.createIdentifier(tag),
undefined,
ctx.factory.createNoSubstitutionTemplateLiteral(stylesheet),
)
),
], ts.NodeFlags.Const)
);
}

/**
* @param {string} stylesheet
* @param {string} filePath
*/
function minifyCss(stylesheet: string, filePath: string) {
try {
const clean = new CleanCSS({ returnPromise: false });
const { styles } = clean.minify(stylesheet);
return styles;
} catch (e) {
// eslint-disable-next-line no-console
console.log('Could not minify ', filePath);
// eslint-disable-next-line no-console
console.error(e);
return stylesheet;
}
}

/**
* Replace .css import specifiers with .css.js import specifiers
*/
export default function(
program: ts.Program,
pluginConfig: PluginConfig & LitCSSOptions,
extras: TransformerExtras,
) {
const tagPkgSpecifier = pluginConfig.specifier ?? 'lit';
const tag = pluginConfig.tag ?? 'css';
return (ctx: ts.TransformationContext) => {
function visitor(node: ts.Node) {
if (ts.isImportDeclaration(node) && !node.importClause?.isTypeOnly) {
const importedStyleSheetSpecifier =
node.moduleSpecifier.getText().replace(/^'(.*)'$/, '$1');
if (importedStyleSheetSpecifier.endsWith('.css')) {
if (pluginConfig.inline) {
const { fileName } = node.getSourceFile();
const dir = pathToFileURL(fileName);
const url = new URL(importedStyleSheetSpecifier, dir);
const content = fs.readFileSync(url, 'utf-8');
const stylesheet = pluginConfig.uglify ? minifyCss(content, url.pathname) : content;
return [
createLitCssImportStatement(
ctx,
node.getSourceFile(),
tagPkgSpecifier,
tag,
),
createLitCssTaggedTemplateLiteral(
ctx,
stylesheet,
node.importClause?.name?.getText(),
tag,
),
];
} else {
return ctx.factory.createImportDeclaration(
node.modifiers,
node.importClause,
ctx.factory.createStringLiteral(`${importedStyleSheetSpecifier}.js`)
);
}
}
}
return ts.visitEachChild(node, visitor, ctx);
}

return (sourceFile: SourceFile) => {
const children = sourceFile.getChildren();

const decl = (children.find(x =>
!ts.isTypeOnlyImportOrExportDeclaration(x) &&
!ts.isNamespaceImport(x) &&
ts.isImportDeclaration(x) &&
x.moduleSpecifier.getText() === tagPkgSpecifier &&
x.importClause?.namedBindings
)) as ImportDeclaration;

const litImportBindings = decl?.importClause?.namedBindings;

const hasStyleImports = children.find(x =>
ts.isImportDeclaration(x) && x.moduleSpecifier.getText().endsWith('.css'));

if (hasStyleImports) {
if (litImportBindings &&
ts.isNamedImports(litImportBindings) &&
!litImportBindings.elements?.some(x => x.getText() === tag)) {
ctx.factory.updateNamedImports(
litImportBindings,
[
...litImportBindings.elements,
ctx.factory.createImportSpecifier(
false,
undefined,
ctx.factory.createIdentifier(tag),
),
]
);
}
}
return ts.visitEachChild(sourceFile, visitor, ctx);
};
};
}

0 comments on commit ac7f745

Please sign in to comment.