diff --git a/README.md b/README.md index bd01ae7..3083587 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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: @@ -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: @@ -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): diff --git a/packages/playwright/playwright.config.ts b/packages/playwright/playwright.config.ts index 93259c9..bdfae11 100644 --- a/packages/playwright/playwright.config.ts +++ b/packages/playwright/playwright.config.ts @@ -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, }, diff --git a/packages/playwright/src/dialects/basic.ts b/packages/playwright/src/dialects/basic.ts deleted file mode 100644 index 6161146..0000000 --- a/packages/playwright/src/dialects/basic.ts +++ /dev/null @@ -1,6 +0,0 @@ -import './styles.css' - -export const className = 'pw-basic' -export default function renderLabel() { - return 'basic css' -} diff --git a/packages/playwright/src/dialects/less.ts b/packages/playwright/src/dialects/less.ts deleted file mode 100644 index bcb2bfb..0000000 --- a/packages/playwright/src/dialects/less.ts +++ /dev/null @@ -1,6 +0,0 @@ -import './theme.less' - -export const className = 'pw-less' -export default function renderLabel() { - return 'less' -} diff --git a/packages/playwright/src/dialects/registry.ts b/packages/playwright/src/dialects/registry.ts deleted file mode 100644 index aad0f2f..0000000 --- a/packages/playwright/src/dialects/registry.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { KnightedCssCombinedModule } from '@knighted/css/loader' - -import Basic, { className as basicClass } from './basic.js' -import { knightedCss as basicCss } from './basic.js?knighted-css' - -import Scss, { className as scssClass } from './scss.js' -import { knightedCss as scssCss } from './scss.js?knighted-css' - -import SassIndented, { className as sassIndentedClass } from './sass-indented.js' -import { knightedCss as sassIndentedCss } from './sass-indented.js?knighted-css' - -import LessComp, { className as lessClass } from './less.js' -import { knightedCss as lessCss } from './less.js?knighted-css' - -import * as VanillaCombined from './vanilla.js?knighted-css&combined' - -type VanillaModuleShape = KnightedCssCombinedModule - -const VanillaModule = VanillaCombined as unknown as VanillaModuleShape -const { - default: VanillaComp, - className: vanillaClass, - badgeClass, - tokenClass, - knightedCss: vanillaCss, -} = VanillaModule - -export type DialectSample = { - className: string - css: string - label: string - testId: string -} - -export const dialects: DialectSample[] = [ - { label: Basic(), className: basicClass, css: basicCss, testId: 'dialect-basic' }, - { label: Scss(), className: scssClass, css: scssCss, testId: 'dialect-scss' }, - { - label: SassIndented(), - className: sassIndentedClass, - css: sassIndentedCss, - testId: 'dialect-sass-indented', - }, - { label: LessComp(), className: lessClass, css: lessCss, testId: 'dialect-less' }, - { - label: VanillaComp(), - className: `${vanillaClass} ${badgeClass} ${tokenClass}`, - css: vanillaCss, - testId: 'dialect-vanilla', - }, -] diff --git a/packages/playwright/src/dialects/sass-indented.ts b/packages/playwright/src/dialects/sass-indented.ts deleted file mode 100644 index 4755171..0000000 --- a/packages/playwright/src/dialects/sass-indented.ts +++ /dev/null @@ -1,6 +0,0 @@ -import './indented.sass' - -export const className = 'pw-sass-indented' -export default function renderLabel() { - return 'sass-indented' -} diff --git a/packages/playwright/src/dialects/scss.ts b/packages/playwright/src/dialects/scss.ts deleted file mode 100644 index bfcb3ac..0000000 --- a/packages/playwright/src/dialects/scss.ts +++ /dev/null @@ -1,6 +0,0 @@ -import './styles.scss' - -export const className = 'pw-scss' -export default function renderLabel() { - return 'scss' -} diff --git a/packages/playwright/src/dialects/vanilla.ts b/packages/playwright/src/dialects/vanilla.ts deleted file mode 100644 index 9edfc5b..0000000 --- a/packages/playwright/src/dialects/vanilla.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { KnightedCssCombinedModule } from '@knighted/css/loader' -import * as VanillaStylesCombined from './vanilla.css.js?knighted-css&combined' - -type VanillaStylesModule = KnightedCssCombinedModule - -const VanillaStyles = VanillaStylesCombined as unknown as VanillaStylesModule -const { vanillaBadgeClass, vanillaCardClass, vanillaTokenClass } = VanillaStyles - -export const className = vanillaCardClass -export const badgeClass = vanillaBadgeClass -export const tokenClass = vanillaTokenClass - -export default function renderLabel() { - return 'vanilla-extract' -} diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 926d1a2..3456dab 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -1,9 +1,8 @@ -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 } @@ -11,4 +10,4 @@ if (typeof document !== 'undefined') { render() } -export { dialects, render } +export { render } diff --git a/packages/playwright/src/dialects/styles.css b/packages/playwright/src/lit-react/cards/basic-card/basic-card.css similarity index 100% rename from packages/playwright/src/dialects/styles.css rename to packages/playwright/src/lit-react/cards/basic-card/basic-card.css diff --git a/packages/playwright/src/lit-react/cards/basic-card/basic-card.tsx b/packages/playwright/src/lit-react/cards/basic-card/basic-card.tsx new file mode 100644 index 0000000..0ab4fd8 --- /dev/null +++ b/packages/playwright/src/lit-react/cards/basic-card/basic-card.tsx @@ -0,0 +1,11 @@ +import './basic-card.css' + +export const BASIC_TEST_ID = 'dialect-basic' + +export function BasicCard() { + return ( + + Native CSS module + + ) +} diff --git a/packages/playwright/src/dialects/theme.less b/packages/playwright/src/lit-react/cards/less-card/less-card.less similarity index 100% rename from packages/playwright/src/dialects/theme.less rename to packages/playwright/src/lit-react/cards/less-card/less-card.less diff --git a/packages/playwright/src/lit-react/cards/less-card/less-card.tsx b/packages/playwright/src/lit-react/cards/less-card/less-card.tsx new file mode 100644 index 0000000..3c3faf5 --- /dev/null +++ b/packages/playwright/src/lit-react/cards/less-card/less-card.tsx @@ -0,0 +1,11 @@ +import './less-card.less' + +export const LESS_TEST_ID = 'dialect-less' + +export function LessCard() { + return ( + + Less modules wire gradients and badges inline. + + ) +} diff --git a/packages/playwright/src/dialects/indented.sass b/packages/playwright/src/lit-react/cards/sass-card/sass-card.sass similarity index 100% rename from packages/playwright/src/dialects/indented.sass rename to packages/playwright/src/lit-react/cards/sass-card/sass-card.sass diff --git a/packages/playwright/src/lit-react/cards/sass-card/sass-card.tsx b/packages/playwright/src/lit-react/cards/sass-card/sass-card.tsx new file mode 100644 index 0000000..d979e09 --- /dev/null +++ b/packages/playwright/src/lit-react/cards/sass-card/sass-card.tsx @@ -0,0 +1,11 @@ +import './sass-card.sass' + +export const SASS_TEST_ID = 'dialect-sass-indented' + +export function SassCard() { + return ( + + Sass indented syntax keeps the badge compact. + + ) +} diff --git a/packages/playwright/src/dialects/styles.scss b/packages/playwright/src/lit-react/cards/scss-card/scss-card.scss similarity index 100% rename from packages/playwright/src/dialects/styles.scss rename to packages/playwright/src/lit-react/cards/scss-card/scss-card.scss diff --git a/packages/playwright/src/lit-react/cards/scss-card/scss-card.tsx b/packages/playwright/src/lit-react/cards/scss-card/scss-card.tsx new file mode 100644 index 0000000..ec53da9 --- /dev/null +++ b/packages/playwright/src/lit-react/cards/scss-card/scss-card.tsx @@ -0,0 +1,11 @@ +import './scss-card.scss' + +export const SCSS_TEST_ID = 'dialect-scss' + +export function ScssCard() { + return ( + + SCSS nesting keeps typography bold. + + ) +} diff --git a/packages/playwright/src/dialects/vanilla.css.ts b/packages/playwright/src/lit-react/cards/vanilla-card/vanilla-card.css.ts similarity index 100% rename from packages/playwright/src/dialects/vanilla.css.ts rename to packages/playwright/src/lit-react/cards/vanilla-card/vanilla-card.css.ts diff --git a/packages/playwright/src/lit-react/cards/vanilla-card/vanilla-card.tsx b/packages/playwright/src/lit-react/cards/vanilla-card/vanilla-card.tsx new file mode 100644 index 0000000..d1e3d14 --- /dev/null +++ b/packages/playwright/src/lit-react/cards/vanilla-card/vanilla-card.tsx @@ -0,0 +1,18 @@ +import { + vanillaBadgeClass, + vanillaCardClass, + vanillaTokenClass, +} from './vanilla-card.css.js' + +export const VANILLA_TEST_ID = 'dialect-vanilla' + +export function VanillaCard() { + return ( + + Vanilla Extract sprinkles inline spacing + tokens. + + ) +} diff --git a/packages/playwright/src/lit-react/constants.ts b/packages/playwright/src/lit-react/constants.ts index 4f935b3..15cddd9 100644 --- a/packages/playwright/src/lit-react/constants.ts +++ b/packages/playwright/src/lit-react/constants.ts @@ -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' diff --git a/packages/playwright/src/lit-react/index.ts b/packages/playwright/src/lit-react/index.ts index d962405..7a3a5be 100644 --- a/packages/playwright/src/lit-react/index.ts +++ b/packages/playwright/src/lit-react/index.ts @@ -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) } diff --git a/packages/playwright/src/lit-react/button-wrapper.ts b/packages/playwright/src/lit-react/lit-host.ts similarity index 55% rename from packages/playwright/src/lit-react/button-wrapper.ts rename to packages/playwright/src/lit-react/lit-host.ts index 122f3b4..d5304b5 100644 --- a/packages/playwright/src/lit-react/button-wrapper.ts +++ b/packages/playwright/src/lit-react/lit-host.ts @@ -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' }, } @@ -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 { @@ -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): void { @@ -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) } } diff --git a/packages/playwright/src/lit-react/showcase.tsx b/packages/playwright/src/lit-react/showcase.tsx index c092463..8faf3e6 100644 --- a/packages/playwright/src/lit-react/showcase.tsx +++ b/packages/playwright/src/lit-react/showcase.tsx @@ -1,26 +1,69 @@ import './app.css' -import type { DialectSample } from '../dialects/registry.js' +import type { JSX } from 'react' + +import { BasicCard, BASIC_TEST_ID } from './cards/basic-card/basic-card.js' +import { LessCard, LESS_TEST_ID } from './cards/less-card/less-card.js' +import { SassCard, SASS_TEST_ID } from './cards/sass-card/sass-card.js' +import { knightedCss as sassCss } from './cards/sass-card/sass-card.js?knighted-css' +import { ScssCard, SCSS_TEST_ID } from './cards/scss-card/scss-card.js' +import { knightedCss as scssCss } from './cards/scss-card/scss-card.js?knighted-css' +import { VanillaCard, VANILLA_TEST_ID } from './cards/vanilla-card/vanilla-card.js' +import { knightedCss as vanillaCss } from './cards/vanilla-card/vanilla-card.js?knighted-css' import { Button } from './button.js' +type DialectCard = { + id: string + css: string + Component: () => JSX.Element +} + +type HostManagedDialect = { + id: string + hint: string + Component: () => JSX.Element +} + +const cards: DialectCard[] = [ + { id: SCSS_TEST_ID, css: scssCss, Component: ScssCard }, + { id: SASS_TEST_ID, css: sassCss, Component: SassCard }, + { id: VANILLA_TEST_ID, css: vanillaCss, Component: VanillaCard }, +] + +const hostManagedDialects: HostManagedDialect[] = [ + { + id: BASIC_TEST_ID, + hint: 'Lit host injects this CSS module', + Component: BasicCard, + }, + { + id: LESS_TEST_ID, + hint: 'Lit host injects the Less-gradient card', + Component: LessCard, + }, +] + type ShowcaseProps = { label: string - dialects: DialectSample[] } -export function Showcase({ label, dialects }: ShowcaseProps) { +export function Showcase({ label }: ShowcaseProps) { return (
- {dialects.map(dialect => ( -
-