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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
[![codecov](https://codecov.io/gh/knightedcodemonkey/css/graph/badge.svg?token=q93Qqwvq6l)](https://codecov.io/gh/knightedcodemonkey/css)
[![NPM version](https://img.shields.io/npm/v/@knighted/css.svg)](https://www.npmjs.com/package/@knighted/css)

`@knighted/css` is a zero-bundler CSS pipeline for JavaScript and TypeScript projects. Point it at an entry module and it walks the graph, compiles every CSS-like dependency (CSS, Sass/SCSS, Less, vanilla-extract), and hands back both a concatenated stylesheet string and optional `.knighted-css.*` selector manifests for type-safe loaders.
`@knighted/css` is a bundler-optional CSS pipeline for JavaScript and TypeScript projects. Use it standalone or plug it into your bundler via the `?knighted-css` loader query—it walks the graph, compiles every CSS-like dependency (CSS, Sass/SCSS, Less, vanilla-extract), and hands back both a concatenated stylesheet string and optional `.knighted-css.*` selector manifests for type-safe loaders.

## What it does (at a glance)

- **Graph walking**: Follows `import` trees the same way Node does (tsconfig `paths`, package `exports`/`imports`, hash specifiers, etc.) using [`oxc-resolver`](https://github.com/oxc-project/oxc-resolver).
- **Multi-dialect compilation**: Runs Sass, Less, Lightning CSS, or vanilla-extract integrations on demand so every dependency ends up as plain CSS.
- **Attribute-aware imports**: Honors static `with { type: "css" }` import attributes (including extensionless/aliased and static dynamic imports) so CSS gets pulled into the graph even when extensions aren’t present.
- **Loader + CLI**: Ship CSS at runtime via `?knighted-css` loader queries or ahead of time via the `css()` API and the `knighted-css-generate-types` command.
- **Shadow DOM + SSR ready**: Inline styles in server renders, ship them alongside web components, or keep classic DOM apps in sync—all without wiring a full bundler.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/css/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![codecov](https://codecov.io/gh/knightedcodemonkey/css/graph/badge.svg?token=q93Qqwvq6l)](https://codecov.io/gh/knightedcodemonkey/css)
[![NPM version](https://img.shields.io/npm/v/@knighted/css.svg)](https://www.npmjs.com/package/@knighted/css)

`@knighted/css` walks your JavaScript/TypeScript module graph, compiles every CSS-like dependency (plain CSS, Sass/SCSS, Less, vanilla-extract), and ships both the concatenated stylesheet string and optional `.knighted-css.*` imports that keep selectors typed. Use it when you need fully materialized styles ahead of runtime—Shadow DOM surfaces, server-rendered routes, static site builds, or any entry point that should inline CSS without spinning up a full bundler.
`@knighted/css` walks your module graph, compiles every CSS-like dependency (plain CSS, Sass/SCSS, Less, vanilla-extract), and ships both the concatenated stylesheet string and optional `.knighted-css.*` imports that keep selectors typed. Use it with or without a bundler: run the `css()` API in scripts/SSR pipelines, or lean on the `?knighted-css` loader query so bundlers import compiled CSS alongside modules. Either path yields fully materialized styles for Shadow DOM surfaces, server-rendered routes, static site builds, or any entry point that should inline CSS.

## Why

Expand All @@ -23,7 +23,7 @@ I needed a single source of truth for UI components that could drop into both li

## Features

- Traverses module graphs with a built-in walker to find transitive style imports (no bundler required).
- Traverses module graphs with a built-in walker to find transitive style imports (bundler optional—works standalone or through bundler loaders), including static import attributes (`with { type: "css" }`) for extensionless or aliased specifiers.
- Resolution parity via [`oxc-resolver`](https://github.com/oxc-project/oxc-resolver): tsconfig `paths`, package `exports` + `imports`, and extension aliasing (e.g., `.css.js` → `.css.ts`) are honored without wiring up a bundler.
- 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, media query optimizations, or specificity boosts.
Expand Down
2 changes: 1 addition & 1 deletion packages/css/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/css",
"version": "1.0.9",
"version": "1.0.10",
"description": "A build-time utility that traverses JavaScript/TypeScript module dependency graphs to extract, compile, and optimize all imported CSS into a single, in-memory string.",
"type": "module",
"main": "./dist/css.js",
Expand Down
173 changes: 164 additions & 9 deletions packages/css/src/moduleGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ interface CollectOptions {
graphOptions?: ModuleGraphOptions
}

type ExtractedSpecifier = {
specifier: string
assertedType?: 'css'
}

type TsconfigLike = string | Record<string, unknown>

interface TsconfigPathsResult {
Expand Down Expand Up @@ -93,7 +98,7 @@ export async function collectStyleImports(
return
}
const specifiers = extractModuleSpecifiers(source, absolutePath)
for (const specifier of specifiers) {
for (const { specifier, assertedType } of specifiers) {
if (!specifier || isBuiltinSpecifier(specifier)) {
continue
}
Expand All @@ -105,6 +110,13 @@ export async function collectStyleImports(
if (!filter(normalized)) {
continue
}
if (assertedType === 'css') {
if (!seenStyles.has(normalized)) {
seenStyles.add(normalized)
styleOrder.push(normalized)
}
continue
}
if (isStyleExtension(normalized, normalizedStyles)) {
if (!seenStyles.has(normalized)) {
seenStyles.add(normalized)
Expand Down Expand Up @@ -202,36 +214,39 @@ async function readSourceFile(filePath: string): Promise<string | undefined> {
}
}

function extractModuleSpecifiers(sourceText: string, filePath: string): string[] {
function extractModuleSpecifiers(
sourceText: string,
filePath: string,
): ExtractedSpecifier[] {
let program
try {
;({ program } = parseSync(filePath, sourceText, { sourceType: 'unambiguous' }))
} catch {
return []
}

const specifiers: string[] = []
const addSpecifier = (raw?: string | null) => {
const specifiers: ExtractedSpecifier[] = []
const addSpecifier = (raw?: string | null, assertedType?: 'css') => {
if (!raw) {
return
}
const normalized = normalizeSpecifier(raw)
if (normalized) {
specifiers.push(normalized)
specifiers.push({ specifier: normalized, assertedType })
}
}

const visitor = new Visitor({
ImportDeclaration(node) {
addSpecifier(node.source?.value)
addSpecifier(node.source?.value, getImportAssertedType(node))
},
ExportNamedDeclaration(node) {
if (node.source) {
addSpecifier(node.source.value)
addSpecifier(node.source.value, getImportAssertedType(node))
}
},
ExportAllDeclaration(node) {
addSpecifier(node.source?.value)
addSpecifier(node.source?.value, getImportAssertedType(node))
},
TSImportEqualsDeclaration(node: TSImportEqualsDeclaration) {
const specifier = extractImportEqualsSpecifier(node)
Expand All @@ -242,7 +257,7 @@ function extractModuleSpecifiers(sourceText: string, filePath: string): string[]
ImportExpression(node: ImportExpression) {
const specifier = getStringFromExpression(node.source)
if (specifier) {
addSpecifier(specifier)
addSpecifier(specifier, getImportExpressionAssertedType(node))
}
},
CallExpression(node) {
Expand Down Expand Up @@ -280,6 +295,146 @@ function normalizeSpecifier(raw: string): string {
return withoutQuery
}

function getImportAssertedType(node: unknown): 'css' | undefined {
const attributes = getImportAttributes(node)
for (const attribute of attributes) {
const key = getAttributeKey(attribute)
const value = getAttributeValue(attribute)
if (key === 'type' && value === 'css') {
return 'css'
}
}
return undefined
}

function getImportAttributes(node: unknown): unknown[] {
const attributes: unknown[] = []
const candidate = node as { [key: string]: unknown }

const withClause = candidate?.withClause as { attributes?: unknown }
if (withClause && Array.isArray(withClause.attributes)) {
attributes.push(...withClause.attributes)
}

const directAttributes = candidate?.attributes
if (Array.isArray(directAttributes)) {
attributes.push(...directAttributes)
}

const assertions = candidate?.assertions
if (Array.isArray(assertions)) {
attributes.push(...assertions)
}

return attributes
}

function getAttributeKey(attribute: unknown): string | undefined {
const attr = attribute as { [key: string]: unknown }
const key = attr?.key as { [key: string]: unknown } | undefined
if (!key) {
return undefined
}
if (typeof (key as { name?: unknown }).name === 'string') {
return (key as { name: string }).name
}
const value = (key as { value?: unknown }).value
if (typeof value === 'string') {
return value
}
return undefined
}

function getAttributeValue(attribute: unknown): string | undefined {
const attr = attribute as { [key: string]: unknown }
const value = attr?.value as { [key: string]: unknown } | unknown
if (typeof value === 'string') {
return value
}
if (value && typeof (value as { value?: unknown }).value === 'string') {
return (value as { value: string }).value
}
return undefined
}

function getImportExpressionAssertedType(node: ImportExpression): 'css' | undefined {
// Stage-3 import attributes proposal shape: import(spec, { with: { type: "css" } })
const options = (node as { options?: Expression | null | undefined }).options
if (!options) {
return undefined
}

const withObject = getStaticObjectProperty(options, 'with')
if (withObject && isObjectExpression(withObject)) {
const typeValue = getStaticObjectString(withObject, 'type')
if (typeValue === 'css') {
return 'css'
}
}

const assertObject = getStaticObjectProperty(options, 'assert')
if (assertObject && isObjectExpression(assertObject)) {
const typeValue = getStaticObjectString(assertObject, 'type')
if (typeValue === 'css') {
return 'css'
}
}

return undefined
}

function isObjectExpression(
expression: Expression,
): (Expression & { type: 'ObjectExpression'; properties: unknown[] }) | undefined {
return expression && expression.type === 'ObjectExpression'
? (expression as Expression & { type: 'ObjectExpression'; properties: unknown[] })
: undefined
}

function getStaticObjectProperty(
expression: Expression,
name: string,
): Expression | undefined {
const objectExpression = isObjectExpression(expression)
if (!objectExpression) {
return undefined
}
for (const prop of objectExpression.properties as unknown[]) {
const maybeProp = prop as { key?: unknown; value?: unknown; type?: string }
if (maybeProp.type && maybeProp.type !== 'Property') {
continue
}
const keyName = getPropertyKeyName(maybeProp.key)
if (keyName === name) {
const value = maybeProp.value as Expression | undefined
if (value) {
return value
}
}
}
return undefined
}

function getPropertyKeyName(key: unknown): string | undefined {
if (!key) return undefined
const asAny = key as { name?: unknown; value?: unknown; type?: string }
if (typeof asAny.name === 'string') {
return asAny.name
}
if (typeof asAny.value === 'string') {
return asAny.value
}
return undefined
}

function getStaticObjectString(expression: Expression, name: string): string | undefined {
const valueExpression = getStaticObjectProperty(expression, name)
if (!valueExpression) {
return undefined
}
return getStringFromExpression(valueExpression)
}

function extractImportEqualsSpecifier(
node: TSImportEqualsDeclaration,
): string | undefined {
Expand Down
Loading