diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index def0979..2cc8b60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index 10977af..ae87cf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11922,7 +11922,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.0.0-rc.13", + "version": "1.0.0-rc.14", "license": "MIT", "dependencies": { "es-module-lexer": "^2.0.0", @@ -12222,7 +12222,7 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.0.0-rc.13", + "@knighted/css": "1.0.0-rc.14", "@knighted/jsx": "^1.4.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/package.json b/packages/css/package.json index d3a6617..0a975dd 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -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", diff --git a/packages/css/test/__snapshots__/generateTypes.snap.json b/packages/css/test/__snapshots__/generateTypes.snap.json new file mode 100644 index 0000000..42616f5 --- /dev/null +++ b/packages/css/test/__snapshots__/generateTypes.snap.json @@ -0,0 +1,4 @@ +{ + "cli-generation-summary": "[log]\n[knighted-css] Selector modules updated: wrote 1, removed 0.\n[knighted-css] Manifest: /selector-modules.json\n[knighted-css] Selector modules are up to date.\n[knighted-css] Manifest: /selector-modules.json\n[warn]", + "cli-help-output": "Usage: knighted-css-generate-types [options]\n\nOptions:\n -r, --root Project root directory (default: cwd)\n -i, --include Additional directories/files to scan (repeatable)\n --out-dir Directory to store selector module manifest cache\n --stable-namespace Stable namespace prefix for generated selector maps\n -h, --help Show this help message" +} diff --git a/packages/css/test/css-walker.test.ts b/packages/css/test/css-walker.test.ts new file mode 100644 index 0000000..7ed969e --- /dev/null +++ b/packages/css/test/css-walker.test.ts @@ -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 + cleanup: () => Promise +} + +async function createProject(prefix: string): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)) + const writeFile = async (rel: string, contents: string): Promise => { + 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 { + 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() + } +}) diff --git a/packages/css/test/fixtures/combined/runtime-entry.ts b/packages/css/test/fixtures/combined/runtime-entry.ts new file mode 100644 index 0000000..e5bd878 --- /dev/null +++ b/packages/css/test/fixtures/combined/runtime-entry.ts @@ -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, +}) diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index c0ee939..a00752c 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -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 | null = null + async function setupFixtureProject(): Promise<{ root: string cleanup: () => Promise @@ -81,6 +88,99 @@ async function pathExists(target: string): Promise { } } +async function loadCliSnapshots(): Promise> { + if (cachedCliSnapshots) { + return cachedCliSnapshots + } + try { + const raw = await fs.readFile(CLI_SNAPSHOT_FILE, 'utf8') + cachedCliSnapshots = JSON.parse(raw) as Record + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code === 'ENOENT') { + cachedCliSnapshots = {} + } else { + throw error + } + } + return cachedCliSnapshots +} + +async function writeCliSnapshots(map: Record): Promise { + 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 { + 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 { + const sections = ['[log]', ...logs, '[warn]', ...warns] + const combined = sections.join('\n') + return normalizeSnapshotText(applyPathPlaceholders(combined, placeholders)) +} + +async function expectCliSnapshot(name: string, value: string): Promise { + 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 { @@ -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]: '', + [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, @@ -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 { diff --git a/packages/css/test/helpers/resolver-fixture.ts b/packages/css/test/helpers/resolver-fixture.ts index 1314f37..b43b0e7 100644 --- a/packages/css/test/helpers/resolver-fixture.ts +++ b/packages/css/test/helpers/resolver-fixture.ts @@ -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' diff --git a/packages/css/test/loader-helpers-runtime.test.ts b/packages/css/test/loader-helpers-runtime.test.ts new file mode 100644 index 0000000..c637429 --- /dev/null +++ b/packages/css/test/loader-helpers-runtime.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import test from 'node:test' + +import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers' + +const testDir = fileURLToPath(new URL('.', import.meta.url)) +const fixturesDir = path.join(testDir, 'fixtures/combined') + +async function importRuntimeEntry() { + const fixtureUrl = pathToFileURL(path.join(fixturesDir, 'runtime-entry.ts')) + return import(fixtureUrl.href) +} + +test('asKnightedCssCombinedModule narrows combined & types payloads', async () => { + const baseModule = await importRuntimeEntry() + const runtimeSelectors = Object.freeze({ + shell: 'knighted-shell', + copy: 'knighted-copy', + }) + + const combinedModule = Object.freeze({ + __esModule: true, + ...baseModule, + knightedCss: '.combined-runtime { color: rebeccapurple; }', + stableSelectors: runtimeSelectors, + }) + + const narrowed = asKnightedCssCombinedModule< + typeof import('./fixtures/combined/runtime-entry.js'), + { stableSelectors: Readonly } + >(combinedModule) + + assert.equal(narrowed.runtimeFeatureFlag, true) + assert.equal(narrowed.runtimeMeta.tone, 'violet') + assert.equal(narrowed.default({ label: 'SSR stream' }), 'Runtime card for SSR stream') + assert.equal(typeof narrowed.CombinedRuntimeDetails(), 'string') + assert.ok(narrowed.knightedCss.includes('.combined-runtime')) + assert.strictEqual(narrowed.stableSelectors, runtimeSelectors) + assert.equal(narrowed.stableSelectors.shell, 'knighted-shell') +}) diff --git a/packages/css/test/loader-queries.types.ts b/packages/css/test/loader-queries.types.ts new file mode 100644 index 0000000..0eb65f3 --- /dev/null +++ b/packages/css/test/loader-queries.types.ts @@ -0,0 +1,20 @@ +import '@knighted/css/loader-queries' + +/** + * This file never executes at runtime. It exists solely so `tsc --project tsconfig.tests.json` + * validates the emitted ambient modules from `@knighted/css/loader-queries`. + */ +type CombinedModule = + typeof import('./fixtures/combined/runtime-entry.ts?knighted-css&combined&types') + +type ExpectTrue = T + +type KnightedCssIsString = ExpectTrue< + CombinedModule['knightedCss'] extends string ? true : false +> +type StableSelectorsAreReadonly = ExpectTrue< + CombinedModule['stableSelectors'] extends Readonly> + ? true + : false +> +export type LoaderQueriesSmokeTest = [KnightedCssIsString, StableSelectorsAreReadonly] diff --git a/packages/css/test/loader_unit.test.ts b/packages/css/test/loader_unit.test.ts index 2757bf9..b1ba4ca 100644 --- a/packages/css/test/loader_unit.test.ts +++ b/packages/css/test/loader_unit.test.ts @@ -2,10 +2,9 @@ import assert from 'node:assert/strict' import path from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' - -import type { LoaderContext } from 'webpack' - import loader, { type KnightedCssLoaderOptions, pitch } from '../src/loader.js' +import { determineSelectorVariant } from '../src/loaderInternals.js' +import type { LoaderContext, Module } from 'webpack' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -14,6 +13,13 @@ type MockLoaderContext = Partial> & { added: Set } +type LoaderCallback = ( + error: Error | null, + result?: string | Buffer, + sourceMap?: object | null, + module?: Module, +) => void + function createMockContext( overrides: Partial> & { added?: Set @@ -232,7 +238,7 @@ test('pitch returns combined module when query includes combined flag', async () const ctx = createMockContext({ resourcePath, resourceQuery: '?knighted-css&combined', - loadModule: (_request, callback) => { + loadModule: (_request: string, callback: LoaderCallback) => { callback(null, "export const Button = () => 'ok';") }, }) @@ -258,7 +264,7 @@ test('pitch injects stableSelectors export when combined types query is used', a const ctx = createMockContext({ resourcePath, resourceQuery: '?knighted-css&combined&types', - loadModule: (_request, callback) => { + loadModule: (_request: string, callback: LoaderCallback) => { callback(null, 'export const Button = () => "ok";') }, }) @@ -359,7 +365,7 @@ test('pitch reuses rawRequest when building proxy module', async () => { _module: { rawRequest: './aliased/entry.js?loaderFlag=1', } as LoaderContext['_module'], - loadModule: (_request, callback) => { + loadModule: (_request: string, callback: LoaderCallback) => { callback(null, 'export const stub = 1;') }, }) @@ -384,7 +390,7 @@ test('combined modules skip default export for vanilla style entries', async () const ctx = createMockContext({ resourcePath, resourceQuery: '?knighted-css&combined', - loadModule: (_request, callback) => { + loadModule: (_request: string, callback: LoaderCallback) => { callback(null, 'export const badge = 1;') }, }) @@ -400,3 +406,95 @@ test('combined modules skip default export for vanilla style entries', async () assert.ok(!/export default __knightedDefault/.test(combinedOutput)) assert.match(combinedOutput, /export \* from/) }) + +test('combined proxy forwards default export when source module has one', async () => { + const resourcePath = path.resolve(__dirname, 'fixtures/combined/default-export.ts') + const ctx = createMockContext({ + resourcePath, + resourceQuery: '?knighted-css&combined', + loadModule: (_request: string, callback: LoaderCallback) => { + callback( + null, + "export default function Demo() { return 'ok' }; export const helper = () => 'helper';", + ) + }, + }) + + const result = await pitch.call( + ctx as LoaderContext, + `${resourcePath}?knighted-css&combined`, + '', + {}, + ) + + const combinedOutput = String(result ?? '') + assert.match( + combinedOutput, + /export default __knightedDefault/, + 'should emit synthetic default', + ) + assert.match(combinedOutput, /export const knightedCss = /) +}) + +test('combined proxy omits synthetic default when named-only flag is provided', async () => { + const resourcePath = path.resolve(__dirname, 'fixtures/combined/named-only.ts') + const ctx = createMockContext({ + resourcePath, + resourceQuery: '?knighted-css&combined&named-only', + loadModule: (_request: string, callback: LoaderCallback) => { + callback(null, 'export const alpha = 1; export const beta = 2;') + }, + }) + + const result = await pitch.call( + ctx as LoaderContext, + `${resourcePath}?knighted-css&combined&named-only`, + '', + {}, + ) + + const combinedOutput = String(result ?? '') + assert.ok( + !/export default __knightedDefault/.test(combinedOutput), + 'named-only variant should not synthesize a default export', + ) + assert.match(combinedOutput, /export const knightedCss = /) +}) + +test('combined&types proxy surfaces runtime stableSelectors export', async () => { + const resourcePath = path.resolve(__dirname, 'fixtures/dialects/basic/entry.js') + const ctx = createMockContext({ + resourcePath, + resourceQuery: '?knighted-css&combined&types', + loadModule: (_request: string, callback: LoaderCallback) => { + callback(null, 'export const Button = () => null;') + }, + }) + + const result = await pitch.call( + ctx as LoaderContext, + `${resourcePath}?knighted-css&combined&types`, + '', + {}, + ) + + const combinedOutput = String(result ?? '') + assert.match( + combinedOutput, + /export const stableSelectors = Object\.freeze\(\{[^}]+\}\);/, + ) + assert.match(combinedOutput, /export const knightedCss = /) +}) + +test('determineSelectorVariant maps query combinations to expected variants', () => { + assert.equal(determineSelectorVariant('?knighted-css&types'), 'types') + assert.equal(determineSelectorVariant('?knighted-css&combined'), 'combined') + assert.equal( + determineSelectorVariant('?knighted-css&combined&named-only'), + 'combinedWithoutDefault', + ) + assert.equal( + determineSelectorVariant('?knighted-css&combined&no-default'), + 'combinedWithoutDefault', + ) +}) diff --git a/packages/css/test/stable-mixins.test.ts b/packages/css/test/stable-mixins.test.ts index 44d9642..e1d014f 100644 --- a/packages/css/test/stable-mixins.test.ts +++ b/packages/css/test/stable-mixins.test.ts @@ -1,23 +1,37 @@ import assert from 'node:assert/strict' -import test from 'node:test' +import { cpSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs' +import os from 'node:os' import path from 'node:path' +import test from 'node:test' import { fileURLToPath } from 'node:url' import * as sass from 'sass' const testDir = fileURLToPath(new URL('.', import.meta.url)) const packageRoot = path.resolve(testDir, '..') -const loadPaths = [packageRoot] + +const sandboxRoot = mkdtempSync(path.join(os.tmpdir(), 'knighted-css-stable-')) +const scopedPackageDir = path.join(sandboxRoot, '@knighted', 'css') +mkdirSync(scopedPackageDir, { recursive: true }) +cpSync(path.join(packageRoot, 'stable'), path.join(scopedPackageDir, 'stable'), { + recursive: true, +}) + +const loadPaths = [sandboxRoot] + +test.after(() => { + rmSync(sandboxRoot, { recursive: true, force: true }) +}) test('stable mixin duplicates the current selector', () => { - const source = `@use 'stable' as knighted; + const source = `@use '@knighted/css/stable' as knighted; .button { @include knighted.stable('button') { color: teal; } }` const { css } = sass.compileString(source, { style: 'expanded', loadPaths }) assert.match(css, /.button,\s*\.knighted-button\s*{[^}]*color: teal;/) }) test('stable-only emits only the deterministic selector', () => { - const source = `@use 'stable' as knighted; + const source = `@use '@knighted/css/stable' as knighted; @include knighted.stable-only('card') { border: 1px solid red; }` const { css } = sass.compileString(source, { style: 'expanded', loadPaths }) assert.match(css, /^\.knighted-card\s*{[^}]*border: 1px solid red;/m) diff --git a/packages/css/tsconfig.tests.json b/packages/css/tsconfig.tests.json index 7b90de0..28dc324 100644 --- a/packages/css/tsconfig.tests.json +++ b/packages/css/tsconfig.tests.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "allowImportingTsExtensions": true, - "noEmit": true + "noEmit": true, + "rootDir": "." }, "include": ["test/**/*.ts"] } diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 2ac3466..6c73471 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -4,9 +4,10 @@ "private": true, "type": "module", "scripts": { - "build": "npm run build:rspack && npm run build:webpack", + "build": "npm run build:rspack && npm run build:webpack && npm run build:ssr", "build:rspack": "npx rspack --config rspack.config.js", "build:webpack": "npx webpack --config webpack.config.js", + "build:ssr": "npx tsx scripts/render-ssr-preview.ts", "check-types": "tsc --noEmit", "preview": "npm run build && npx http-server . -p 4174", "serve": "npx http-server dist -p 4174", @@ -14,7 +15,7 @@ "pretest": "npm run build" }, "dependencies": { - "@knighted/css": "1.0.0-rc.13", + "@knighted/css": "1.0.0-rc.14", "@knighted/jsx": "^1.4.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/playwright/scripts/render-ssr-preview.ts b/packages/playwright/scripts/render-ssr-preview.ts new file mode 100644 index 0000000..3d2116e --- /dev/null +++ b/packages/playwright/scripts/render-ssr-preview.ts @@ -0,0 +1,58 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { css as extractCss } from '@knighted/css' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const pkgRoot = path.resolve(__dirname, '..') +const distDir = path.join(pkgRoot, 'dist') +const ssrEntry = path.join(pkgRoot, 'src/ssr/inline-entry.ts') +const targetFile = path.join(distDir, 'ssr-inline.html') + +async function renderSsrPreview() { + const ssrCss = await extractCss(ssrEntry, { + cwd: pkgRoot, + lightningcss: { minify: true, sourceMap: false }, + }) + + const markup = ` +
+
+

Server rendered

+

Styles arrive with HTML, not hydration

+

+ This fragment was pre-rendered alongside its compiled CSS using @knighted/css. + JavaScript is optional—perfect for SSR streaming and static sites. +

+
    +
  • Deterministic selectors
  • +
  • Inline critical CSS
  • +
  • Hydrate only what you need
  • +
+
+
+`.trim() + + const document = ` + + + + SSR Inline Preview + + + + ${markup} + + +` + + await mkdir(distDir, { recursive: true }) + await writeFile(targetFile, document) +} + +renderSsrPreview().catch(error => { + console.error('[knighted-css/playwright] Failed to render SSR preview.') + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/playwright/src/ssr/inline-entry.ts b/packages/playwright/src/ssr/inline-entry.ts new file mode 100644 index 0000000..d68cd5f --- /dev/null +++ b/packages/playwright/src/ssr/inline-entry.ts @@ -0,0 +1 @@ +import './inline.css' diff --git a/packages/playwright/src/ssr/inline.css b/packages/playwright/src/ssr/inline.css new file mode 100644 index 0000000..17f7e20 --- /dev/null +++ b/packages/playwright/src/ssr/inline.css @@ -0,0 +1,75 @@ +:root { + color-scheme: light; + --ssr-card-bg: #f8fafc; + --ssr-card-border: rgba(15, 23, 42, 0.08); + --ssr-card-accent: #4c1d95; + --ssr-card-muted: #475569; + font-family: + 'Inter', + system-ui, + -apple-system, + BlinkMacSystemFont, + sans-serif; +} + +.ssr-inline-root { + display: flex; + justify-content: center; + padding: 3rem 1.5rem; + background: radial-gradient(circle at top, rgba(76, 29, 149, 0.08), transparent 65%); +} + +.ssr-inline-card { + max-width: 28rem; + border-radius: 1.25rem; + border: 1px solid var(--ssr-card-border); + background-color: var(--ssr-card-bg); + box-shadow: 0 25px 60px rgba(15, 23, 42, 0.08); + padding: 2rem; +} + +.ssr-inline-card__eyebrow { + font-size: 0.85rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(76, 29, 149, 0.85); + margin-bottom: 0.5rem; +} + +.ssr-inline-card__title { + font-size: 1.65rem; + line-height: 1.3; + margin: 0 0 0.75rem; + color: var(--ssr-card-accent); +} + +.ssr-inline-card__body { + margin: 0; + color: var(--ssr-card-muted); + line-height: 1.65; +} + +.ssr-inline-card__list { + margin: 1.5rem 0 0; + padding: 0; + display: grid; + gap: 0.75rem; + list-style: none; +} + +.ssr-inline-card__list-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 500; + color: #0f172a; +} + +.ssr-inline-card__list-item::before { + content: ''; + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: linear-gradient(120deg, #f472b6, #9333ea, #4c1d95); + flex-shrink: 0; +} diff --git a/packages/playwright/test/styles.spec.ts b/packages/playwright/test/styles.spec.ts index a2a2af3..c17d67d 100644 --- a/packages/playwright/test/styles.spec.ts +++ b/packages/playwright/test/styles.spec.ts @@ -75,3 +75,27 @@ test('vanilla-extract sprinkles compose utility classes', async ({ page }) => { expect(metrics.gap).not.toBe('0px') expect(metrics.letterSpacing).not.toBe('') }) + +test.describe('ssr inline preview', () => { + test.use({ javaScriptEnabled: false }) + + test('renders inlined knighted-css output without hydration', async ({ page }) => { + await page.goto('/dist/ssr-inline.html', { waitUntil: 'domcontentloaded' }) + + const ssrRoot = page.getByTestId('ssr-inline-root') + await expect(ssrRoot).toBeVisible() + + const inlineStyle = page.locator('style[data-ssr-inline]') + await expect(inlineStyle).toHaveCount(1) + const inlineCss = await inlineStyle + .first() + .evaluate(node => (node.textContent ?? '').trim()) + expect(inlineCss).toContain('.ssr-inline-card__title') + + const headingColor = await page + .locator('.ssr-inline-card__title') + .evaluate(node => getComputedStyle(node as HTMLElement).color) + + expect(headingColor).toBe('rgb(76, 29, 149)') + }) +})