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
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,28 @@

`@knighted/css` is a build-time helper that walks a JavaScript/TypeScript module graph, finds every CSS-like dependency (plain CSS, Sass/SCSS, Less, vanilla-extract), compiles them, and returns a single concatenated stylesheet string. It is designed for workflows where you want fully materialized styles ahead of time—feeding Lit components, server-rendered routes, static site builds, or any pipeline that needs all CSS for a specific entry point without running a full bundler.

## Quick Links

- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [API](#api)
- [Examples](#examples)

## Features

- Traverses module graphs using [`dependency-tree`](https://github.com/dependents/node-dependency-tree) to find transitive style imports.
- Compiles `*.css`, `*.scss`, `*.sass`, `*.less`, and `*.css.ts` (vanilla-extract) files out of the box.
- Optional post-processing via [`lightningcss`](https://github.com/parcel-bundler/lightningcss) for minification, prefixing, and media query optimizations.
- Optional post-processing via [`lightningcss`](https://github.com/parcel-bundler/lightningcss) for minification, prefixing, media query optimizations, or specificity boosts.
- Pluggable resolver/filter hooks for custom module resolution (e.g., Rspack/Vite/webpack aliases) or selective inclusion.
- Peer-resolution helper for optional toolchains (`sass`, `less`, `@vanilla-extract/integration`) so consumers control their dependency graph.
- First-class loader (`@knighted/css/loader`) so bundlers can import compiled CSS alongside their modules via `?knighted-css`.

## Requirements

- Node.js `>= 22.15.0`
- npm `>= 10.9.0`
- Install peer toolchains you intend to use (`sass`, `less`, `@vanilla-extract/integration`, `@vanilla-extract/recipes`, etc.).
- Install peer toolchains you intend to use (`sass`, `less`, `@vanilla-extract/integration`, etc.).

## Installation

Expand Down Expand Up @@ -77,6 +86,12 @@ Typical customizations:

## Examples

- [Generate standalone stylesheets](#generate-standalone-stylesheets)
- [Inline CSS during SSR](#inline-css-during-ssr)
- [Bundler loader](#bundler-loader-knighted-css-query)
- [Custom resolver](#custom-resolver-enhanced-resolve-example)
- [Specificity boost](#specificity-boost)

### Generate standalone stylesheets

```ts
Expand Down Expand Up @@ -170,6 +185,9 @@ customElements.define('button-wrapper', ButtonWrapper)

The loader appends `export const knightedCss = "/* compiled css */"` to the module when imported with `?knighted-css`. Keep your main module import separate to preserve its typing; use the query import only for the CSS string.

> [!TIP]
> The Playwright Rspack demo shows how a Lit host can import specific dialects with `?knighted-css` and pipe them straight into `LitElement.styles`. See [packages/playwright/src/lit-react/lit-host.ts](packages/playwright/src/lit-react/lit-host.ts) for the shadow-root wiring.

#### TypeScript support for loader queries

Until we publish the ambient declarations to npm, copy [`packages/types/loader-queries.d.ts`](./packages/types/loader-queries.d.ts) into your project (or reference it directly via `typeRoots`). That file declares the two query patterns we rely on:
Expand Down Expand Up @@ -201,7 +219,7 @@ const { Button, knightedCss } = combined as KnightedCssCombinedModule<

You can mix and match: regular `?knighted-css` imports keep strong module typings and just add the CSS string, while `?knighted-css&combined` dedupes your CSS loader pipeline when you need everything at once.

### vanilla-extract loader guidance
#### vanilla-extract loader guidance

vanilla-extract files (`*.css.ts`) compile down to CommonJS by default. That works out of the box for the loader—both `?knighted-css` and `?knighted-css&combined` queries emit `module.exports` artifacts plus the injected `knightedCss` string. Most bundlers happily consume that shape. When you _also_ need the compiled module to behave like a native ESM module (e.g., your bundler expects `export` statements so it can treeshake or when you import via extension aliases), enable the loader’s opt-in transform:

Expand All @@ -227,6 +245,17 @@ The `vanilla.transformToEsm` flag runs a small post-pass that strips the CJS boi
> [!IMPORTANT]
> Only enable `vanilla.transformToEsm` when your bundler really requires ESM output. Leaving the transform off keeps the vanilla-extract module identical to what the upstream compiler produced, which is often preferable if the rest of your toolchain expects CommonJS. The loader no longer toggles this transform automatically—combined imports stay fast, but you remain in full control of when the conversion occurs.

If your build pipeline can gracefully consume both module syntaxes (for example, webpack or Rspack projects that treat the vanilla-extract integration bundle as CommonJS), you may get the desired behavior simply by forcing those files through the “auto” parser instead of rewriting them:

```js
{
test: /@vanilla-extract\/integration/,
type: 'javascript/auto',
}
```

That hint keeps the upstream CommonJS helpers intact while still letting the rest of your app compile as native ESM. It’s worth trying first if you’d rather avoid the transform and your bundler already mixes module systems without issue. Flip `vanilla.transformToEsm` back on whenever you hit a toolchain that insists on pure ESM output.

### Custom resolver (enhanced-resolve example)

If your project uses aliases or nonstandard resolution, plug in a custom resolver. Here’s how to use [`enhanced-resolve`](https://github.com/webpack/enhanced-resolve):
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ if (isCI) {
export default defineConfig({
testDir: 'test',
timeout: 30_000,
retries: isCI ? 2 : 0,
retries: isCI ? 1 : 0,
expect: {
timeout: 10_000,
},
Expand Down
6 changes: 0 additions & 6 deletions packages/playwright/src/dialects/basic.ts

This file was deleted.

6 changes: 0 additions & 6 deletions packages/playwright/src/dialects/less.ts

This file was deleted.

51 changes: 0 additions & 51 deletions packages/playwright/src/dialects/registry.ts

This file was deleted.

6 changes: 0 additions & 6 deletions packages/playwright/src/dialects/sass-indented.ts

This file was deleted.

6 changes: 0 additions & 6 deletions packages/playwright/src/dialects/scss.ts

This file was deleted.

15 changes: 0 additions & 15 deletions packages/playwright/src/dialects/vanilla.ts

This file was deleted.

5 changes: 2 additions & 3 deletions packages/playwright/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { dialects } from './dialects/registry.js'
import { renderLitReactDemo } from './lit-react/index.js'

function render() {
const root = document.getElementById('app') ?? document.body
renderLitReactDemo(root, dialects)
renderLitReactDemo(root)
return root
}

if (typeof document !== 'undefined') {
render()
}

export { dialects, render }
export { render }
11 changes: 11 additions & 0 deletions packages/playwright/src/lit-react/cards/basic-card/basic-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import './basic-card.css'

export const BASIC_TEST_ID = 'dialect-basic'

export function BasicCard() {
return (
<span className="pw-basic" data-testid={BASIC_TEST_ID}>
Native CSS module
</span>
)
}
11 changes: 11 additions & 0 deletions packages/playwright/src/lit-react/cards/less-card/less-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import './less-card.less'

export const LESS_TEST_ID = 'dialect-less'

export function LessCard() {
return (
<span className="pw-less" data-testid={LESS_TEST_ID}>
Less modules wire gradients and badges inline.
</span>
)
}
11 changes: 11 additions & 0 deletions packages/playwright/src/lit-react/cards/sass-card/sass-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import './sass-card.sass'

export const SASS_TEST_ID = 'dialect-sass-indented'

export function SassCard() {
return (
<span className="pw-sass-indented" data-testid={SASS_TEST_ID}>
Sass indented syntax keeps the badge compact.
</span>
)
}
11 changes: 11 additions & 0 deletions packages/playwright/src/lit-react/cards/scss-card/scss-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import './scss-card.scss'

export const SCSS_TEST_ID = 'dialect-scss'

export function ScssCard() {
return (
<span className="pw-scss" data-testid={SCSS_TEST_ID}>
<span className="nested">SCSS nesting keeps typography bold.</span>
</span>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {
vanillaBadgeClass,
vanillaCardClass,
vanillaTokenClass,
} from './vanilla-card.css.js'

export const VANILLA_TEST_ID = 'dialect-vanilla'

export function VanillaCard() {
return (
<span
className={`${vanillaCardClass} ${vanillaBadgeClass} ${vanillaTokenClass}`}
data-testid={VANILLA_TEST_ID}
>
Vanilla Extract sprinkles inline spacing + tokens.
</span>
)
}
4 changes: 2 additions & 2 deletions packages/playwright/src/lit-react/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export const BUTTON_WRAPPER_TAG = 'pw-button-wrapper'
export const LIT_REACT_TEST_ID = 'readme-button-wrapper'
export const LIT_HOST_TAG = 'pw-lit-host'
export const LIT_REACT_TEST_ID = 'readme-lit-host'
12 changes: 5 additions & 7 deletions packages/playwright/src/lit-react/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import type { DialectSample } from '../dialects/registry.js'
import { BUTTON_WRAPPER_TAG, LIT_REACT_TEST_ID } from './constants.js'
import { ButtonWrapper, ensureButtonWrapperDefined } from './button-wrapper.js'
import { LIT_HOST_TAG, LIT_REACT_TEST_ID } from './constants.js'
import { LitHost, ensureLitHostDefined } from './lit-host.js'

export function renderLitReactDemo(root: HTMLElement, dialects: DialectSample[]): void {
ensureButtonWrapperDefined()
export function renderLitReactDemo(root: HTMLElement): void {
ensureLitHostDefined()
const mountPoint = root ?? document.body
const host = document.createElement(BUTTON_WRAPPER_TAG) as ButtonWrapper
const host = document.createElement(LIT_HOST_TAG) as LitHost
host.dataset.testid = LIT_REACT_TEST_ID
host.setAttribute('cta-label', 'Launch CSS Build')
host.setDialects(dialects)
mountPoint.appendChild(host)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
import { reactJsx } from '@knighted/jsx/react'
import { createRoot, type Root } from 'react-dom/client'
import { LitElement, html, unsafeCSS, type PropertyValues } from 'lit'
import { LitElement, css, html, unsafeCSS, type PropertyValues } from 'lit'

import type { DialectSample } from '../dialects/registry.js'
import { Showcase } from './showcase.js'
import { knightedCss as reactStyles } from './showcase.js?knighted-css'
import { BUTTON_WRAPPER_TAG } from './constants.js'
import { knightedCss as basicDialectCss } from './cards/basic-card/basic-card.js?knighted-css'
import { knightedCss as lessDialectCss } from './cards/less-card/less-card.js?knighted-css'
import { LIT_HOST_TAG } from './constants.js'

export class ButtonWrapper extends LitElement {
static styles = [unsafeCSS(reactStyles)]
const hostManagedDialects = css`
${unsafeCSS(basicDialectCss)}
${unsafeCSS(lessDialectCss)}

[data-host-styled] {
border-color: #38bdf8;
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.4);
}

[data-host-styled] .host-managed-hint {
position: absolute;
bottom: 0.75rem;
right: 1rem;
margin: 0;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #38bdf8;
}
`

export class LitHost extends LitElement {
static styles = [unsafeCSS(reactStyles), hostManagedDialects]
static properties = {
ctaLabel: { type: String, attribute: 'cta-label' },
}
Expand All @@ -19,7 +42,6 @@ export class ButtonWrapper extends LitElement {
* shadow Lit's accessors and prevent attr -> prop updates (see Lit warning).
*/
declare ctaLabel: string
#dialects: DialectSample[] = []
#reactRoot?: Root

firstUpdated(): void {
Expand All @@ -42,19 +64,10 @@ export class ButtonWrapper extends LitElement {
this.#renderReactTree()
}

setDialects(dialects: DialectSample[]): void {
this.#dialects = dialects
if (this.isConnected) {
this.#renderReactTree()
}
}

#renderReactTree(): void {
if (!this.#reactRoot || this.#dialects.length === 0) return
if (!this.#reactRoot) return
const label = this.ctaLabel ?? 'React Button'
this.#reactRoot.render(
reactJsx`<${Showcase} label=${label} dialects=${this.#dialects} />`,
)
this.#reactRoot.render(reactJsx`<${Showcase} label=${label} />`)
}

protected updated(changed: PropertyValues<this>): void {
Expand All @@ -69,8 +82,8 @@ export class ButtonWrapper extends LitElement {
}
}

export function ensureButtonWrapperDefined(): void {
if (!customElements.get(BUTTON_WRAPPER_TAG)) {
customElements.define(BUTTON_WRAPPER_TAG, ButtonWrapper)
export function ensureLitHostDefined(): void {
if (!customElements.get(LIT_HOST_TAG)) {
customElements.define(LIT_HOST_TAG, LitHost)
}
}
Loading