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
11 changes: 5 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,20 @@ on:

jobs:
ci:
name: CI (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
name: CI (Node ${{ matrix.version }}-${{ matrix.os }})
strategy:
fail-fast: false
matrix:
node-version:
- '22.17.0'
- '24.11.1'
os: [ubuntu-latest, windows-latest]
version: ['22.17.0', '24.11.1']
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.3.0
with:
node-version: ${{ matrix.node-version }}
node-version: ${{ matrix.version }}
- name: Install Dependencies
run: npm ci
- name: Save error log
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.

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.0-rc.13",
"version": "1.0.0-rc.14",
"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
4 changes: 4 additions & 0 deletions packages/css/test/__snapshots__/generateTypes.snap.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"cli-generation-summary": "[log]\n[knighted-css] Selector modules updated: wrote 1, removed 0.\n[knighted-css] Manifest: <outDir>/selector-modules.json\n[knighted-css] Selector modules are up to date.\n[knighted-css] Manifest: <outDir>/selector-modules.json\n[warn]",
"cli-help-output": "Usage: knighted-css-generate-types [options]\n\nOptions:\n -r, --root <path> Project root directory (default: cwd)\n -i, --include <path> Additional directories/files to scan (repeatable)\n --out-dir <path> Directory to store selector module manifest cache\n --stable-namespace <name> Stable namespace prefix for generated selector maps\n -h, --help Show this help message"
}
152 changes: 152 additions & 0 deletions packages/css/test/css-walker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'

import { cssWithMeta } from '../src/css.ts'
import type { CssResolver } from '../src/types.js'

interface Project {
root: string
file: (rel: string) => string
writeFile: (rel: string, contents: string) => Promise<string>
cleanup: () => Promise<void>
}

async function createProject(prefix: string): Promise<Project> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
const writeFile = async (rel: string, contents: string): Promise<string> => {
const target = path.join(root, rel)
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, contents, 'utf8')
return target
}
return {
root,
file: rel => path.join(root, rel),
writeFile,
cleanup: () => fs.rm(root, { recursive: true, force: true }),
}
}

async function realpathAll(paths: string[]): Promise<string[]> {
return Promise.all(paths.map(filePath => fs.realpath(filePath)))
}

test('css walker dedupes style modules and preserves discovery order', async () => {
const project = await createProject('knighted-css-walker-order-')
try {
await project.writeFile('styles/reset.css', '/* reset */\n.reset { color: #111; }')
await project.writeFile('styles/shared.css', '/* shared */\n.shared { color: #222; }')
await project.writeFile(
'components/widget.css',
'/* widget */\n.widget { color: #333; }',
)
await project.writeFile('styles/async.css', '/* async */\n.async { color: #444; }')
await project.writeFile(
'styles/nested/deep.css',
'/* deep */\n.deep { color: #555; }',
)

const entrySource = `import './styles/reset.css'
import './shared.ts'
await import('./async-entry.ts')
`
await project.writeFile('entry.ts', entrySource)

const sharedSource = `import './styles/shared.css'
import './components/widget.ts'
import './styles/reset.css'
`
await project.writeFile('shared.ts', sharedSource)

const widgetSource = "import './widget.css'\n"
await project.writeFile('components/widget.ts', widgetSource)

const asyncEntrySource = `import './styles/async.css'
export async function load() {
await import('./nested/deep.ts')
}
`
await project.writeFile('async-entry.ts', asyncEntrySource)

await project.writeFile('nested/deep.ts', "import '../styles/nested/deep.css'\n")

const { css, files } = await cssWithMeta(project.file('entry.ts'))

const expectedOrder = [
project.file('styles/reset.css'),
project.file('styles/shared.css'),
project.file('components/widget.css'),
project.file('styles/async.css'),
project.file('styles/nested/deep.css'),
]

assert.deepEqual(await realpathAll(files), await realpathAll(expectedOrder))

const markers = [
'/* reset */',
'/* shared */',
'/* widget */',
'/* async */',
'/* deep */',
]
for (const marker of markers) {
const occurrences = css.split(marker).length - 1
assert.equal(occurrences, 1, `expected ${marker} to appear exactly once`)
}
for (let i = 1; i < markers.length; i += 1) {
const prevIndex = css.indexOf(markers[i - 1])
const nextIndex = css.indexOf(markers[i])
assert.ok(
prevIndex >= 0 && nextIndex > prevIndex,
`${markers[i - 1]} should precede ${markers[i]}`,
)
}
} finally {
await project.cleanup()
}
})

test('css walker honors custom resolver mappings for nonstandard specifiers', async () => {
const project = await createProject('knighted-css-walker-resolver-')
try {
await project.writeFile('styles/global.css', '/* global */\n.global { color: #666; }')
await project.writeFile('styles/button.css', '/* button */\n.button { color: #777; }')

const entrySource = `import '@pkg/global.css'
import './view.ts'
`
await project.writeFile('entry.ts', entrySource)

const viewSource = "import '@shared/button.css'\n"
await project.writeFile('view.ts', viewSource)

const resolver: CssResolver = async specifier => {
if (specifier.startsWith('@pkg/')) {
const relative = specifier.replace(/^@pkg\//, 'styles/')
return project.file(relative)
}
if (specifier === '@shared/button.css') {
return project.file('styles/button.css')
}
return undefined
}

const { css, files } = await cssWithMeta(project.file('entry.ts'), {
resolver,
})

const expected = [
project.file('styles/global.css'),
project.file('styles/button.css'),
]

assert.deepEqual(await realpathAll(files), await realpathAll(expected))
assert.ok(css.includes('/* global */'))
assert.ok(css.includes('/* button */'))
} finally {
await project.cleanup()
}
})
21 changes: 21 additions & 0 deletions packages/css/test/fixtures/combined/runtime-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type CombinedRuntimeCardProps = {
label?: string
}

export default function CombinedRuntimeCard(
props: CombinedRuntimeCardProps = {},
): string {
const label = props.label ?? 'Knighted CSS'
return `Runtime card for ${label}`
}

export function CombinedRuntimeDetails(): string {
return 'details rendered from combined runtime entry'
}

export const runtimeFeatureFlag = true

export const runtimeMeta = Object.freeze({
tone: 'violet',
tags: ['combined', 'types'] as const,
})
110 changes: 106 additions & 4 deletions packages/css/test/generateTypes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import {

const __dirname = path.dirname(fileURLToPath(import.meta.url))

const SNAPSHOT_DIR = path.join(__dirname, '__snapshots__')
const CLI_SNAPSHOT_FILE = path.join(SNAPSHOT_DIR, 'generateTypes.snap.json')
const UPDATE_SNAPSHOTS =
process.env.UPDATE_SNAPSHOTS === '1' || process.env.UPDATE_SNAPSHOTS === 'true'

let cachedCliSnapshots: Record<string, string> | null = null

async function setupFixtureProject(): Promise<{
root: string
cleanup: () => Promise<void>
Expand Down Expand Up @@ -81,6 +88,99 @@ async function pathExists(target: string): Promise<boolean> {
}
}

async function loadCliSnapshots(): Promise<Record<string, string>> {
if (cachedCliSnapshots) {
return cachedCliSnapshots
}
try {
const raw = await fs.readFile(CLI_SNAPSHOT_FILE, 'utf8')
cachedCliSnapshots = JSON.parse(raw) as Record<string, string>
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code === 'ENOENT') {
cachedCliSnapshots = {}
} else {
throw error
}
}
return cachedCliSnapshots
}

async function writeCliSnapshots(map: Record<string, string>): Promise<void> {
cachedCliSnapshots = map
await fs.mkdir(SNAPSHOT_DIR, { recursive: true })
await fs.writeFile(CLI_SNAPSHOT_FILE, `${JSON.stringify(map, null, 2)}\n`)
}

function normalizeSnapshotText(value: string): string {
let next = value.replace(/\r\n/g, '\n')
if (path.sep === '\\') {
next = next.replace(/\\/g, '/')
}
return next.trimEnd()
}

function replaceAllVariants(value: string, raw: string, token: string): string {
const posix = raw.split(path.sep).join('/')
const win = raw.split(path.sep).join('\\')
const variants = new Set([raw, path.normalize(raw), posix, win])
let result = value
for (const variant of variants) {
if (!variant || variant === token) {
continue
}
result = result.split(variant).join(token)
}
return result
}

function applyPathPlaceholders(
value: string,
placeholders: Record<string, string>,
): string {
let result = value
const entries = Object.entries(placeholders).sort(([a], [b]) => b.length - a.length)
for (const [raw, token] of entries) {
if (!raw) {
continue
}
result = replaceAllVariants(result, raw, token)
}
return result
}

function buildCliTranscript(
logs: string[],
warns: string[],
placeholders: Record<string, string> = {},
): string {
const sections = ['[log]', ...logs, '[warn]', ...warns]
const combined = sections.join('\n')
return normalizeSnapshotText(applyPathPlaceholders(combined, placeholders))
}

async function expectCliSnapshot(name: string, value: string): Promise<void> {
const normalized = normalizeSnapshotText(value)
const snapshots = await loadCliSnapshots()
const existing = snapshots[name]
if (UPDATE_SNAPSHOTS) {
if (existing !== normalized) {
snapshots[name] = normalized
await writeCliSnapshots(snapshots)
}
return
}
assert.ok(
existing,
`Snapshot "${name}" is missing. Re-run with UPDATE_SNAPSHOTS=1 to record it.`,
)
assert.equal(
normalized,
existing,
`Snapshot mismatch for "${name}". Re-run with UPDATE_SNAPSHOTS=1 to update.`,
)
}

test('generateTypes emits declarations and reuses cache', async () => {
const project = await setupFixtureProject()
try {
Expand Down Expand Up @@ -356,9 +456,11 @@ test('runGenerateTypesCli executes generation and reports summaries', async () =
console.log = originalLog
console.warn = originalWarn
}
assert.ok(logs.some(log => log.includes('Selector modules updated')))
assert.ok(logs.some(log => log.includes('Selector modules are up to date.')))
assert.equal(warns.length, 0)
const transcript = buildCliTranscript(logs, warns, {
[project.root]: '<projectRoot>',
[outDir]: '<outDir>',
})
await expectCliSnapshot('cli-generation-summary', transcript)
const manifestPath = path.join(outDir, 'selector-modules.json')
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record<
string,
Expand All @@ -379,7 +481,7 @@ test('runGenerateTypesCli prints help output when requested', async () => {
} finally {
console.log = originalLog
}
assert.ok(printed.some(line => line.includes('Usage: knighted-css-generate-types')))
await expectCliSnapshot('cli-help-output', printed.join('\n'))
})
test('generateTypes internals support selector module helpers', async () => {
const {
Expand Down
7 changes: 3 additions & 4 deletions packages/css/test/helpers/resolver-fixture.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'

import type { CssResolver } from '../../src/css.js'

const fixturesRoot = path.resolve(
path.dirname(new URL(import.meta.url).pathname),
'../fixtures/resolvers',
)
const helperDir = fileURLToPath(new URL('.', import.meta.url))
const fixturesRoot = path.resolve(helperDir, '../fixtures/resolvers')

type FixtureName = 'rspack' | 'vite' | 'webpack'

Expand Down
Loading