diff --git a/package-lock.json b/package-lock.json index 15e9df4..c7aedd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1181,16 +1181,16 @@ } }, "node_modules/@knighted/jsx": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@knighted/jsx/-/jsx-1.7.5.tgz", - "integrity": "sha512-CMIOe5pMIvrGQXfI+ZN74lnOQBJ4vl87+xyYEvUCWokChwgIs62DKGpu5nyqSd+o8u5yJyWEVKrY0OPgCJOaHg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@knighted/jsx/-/jsx-1.7.6.tgz", + "integrity": "sha512-qxUyZbGFXWn4bXhWCcb1nM8ESsFJ9KSimUlrxrMn/lWBmH5H9sLA/zJpZU9i6h9JR1R24puv2mSAS7XzalyGaA==", "license": "MIT", "dependencies": { "@napi-rs/wasm-runtime": "^1.1.0", "magic-string": "^0.30.21", "oxc-parser": "^0.105.0", "property-information": "^7.1.0", - "tar": "^7.5.4" + "tar": "^7.5.7" }, "bin": { "jsx": "dist/cli/init.js" @@ -10181,9 +10181,9 @@ } }, "node_modules/tar": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", - "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -11437,7 +11437,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.2.0-rc.1", + "version": "1.2.0-rc.2", "license": "MIT", "dependencies": { "es-module-lexer": "^2.0.0", @@ -11709,8 +11709,8 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.2.0-rc.1", - "@knighted/jsx": "^1.7.5", + "@knighted/css": "1.2.0-rc.2", + "@knighted/jsx": "^1.7.6", "lit": "^3.2.1", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/packages/css/package.json b/packages/css/package.json index 173a6bf..dfa8fd0 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,12 +1,15 @@ { "name": "@knighted/css", - "version": "1.2.0-rc.1", + "version": "1.2.0-rc.2", "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", "types": "./types.d.ts", "typesVersions": { "*": { + "browser": [ + "./dist/browser.d.ts" + ], "loader": [ "./dist/loader.d.ts" ], @@ -36,6 +39,10 @@ "import": "./dist/css.js", "require": "./dist/cjs/css.cjs" }, + "./browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.js" + }, "./loader": { "types": "./dist/loader.d.ts", "import": "./dist/loader.js", diff --git a/packages/css/src/browser.ts b/packages/css/src/browser.ts new file mode 100644 index 0000000..02f3c01 --- /dev/null +++ b/packages/css/src/browser.ts @@ -0,0 +1,152 @@ +export type BrowserDialect = 'css' | 'sass' | 'less' | 'module' + +export type CssFromSourceResult = { + ok: true + css: string + exports?: Record +} + +export type CssFromSourceError = { + ok: false + error: { + message: string + code?: string + } +} + +export type CssFromSourceResponse = CssFromSourceResult | CssFromSourceError + +export type SassLike = { + compile?: ( + source: string, + options?: Record, + ) => { css: string } | { css: { toString: () => string } } + compileString?: ( + source: string, + options?: Record, + ) => { css: string } | { css: { toString: () => string } } + compileStringAsync?: ( + source: string, + options?: Record, + ) => Promise<{ css: string } | { css: { toString: () => string } }> +} + +export type LessLike = { + render: (source: string, options?: Record) => Promise<{ css: string }> +} + +export type LightningCssWasm = { + transform: (options: { filename?: string; code: Uint8Array; cssModules?: boolean }) => { + code: Uint8Array + exports?: Record + } +} + +export type CssFromSourceOptions = { + dialect: BrowserDialect + filename?: string + sass?: SassLike + less?: LessLike + lightningcss?: LightningCssWasm + sassOptions?: Record + lessOptions?: Record +} + +const defaultFilename = 'input.css' + +function resolveCssText(value: { css: unknown }): string { + const raw = value.css + if (typeof raw === 'string') { + return raw + } + if (raw && typeof (raw as { toString?: unknown }).toString === 'function') { + return String((raw as { toString: () => string }).toString()) + } + return '' +} + +function toErrorResult(error: unknown): CssFromSourceError { + if (error && typeof error === 'object') { + const message = + 'message' in error && typeof (error as { message?: unknown }).message === 'string' + ? (error as { message: string }).message + : 'Unknown error' + const code = + 'code' in error && typeof (error as { code?: unknown }).code === 'string' + ? (error as { code: string }).code + : undefined + return { ok: false, error: { message, code } } + } + return { ok: false, error: { message: String(error) } } +} + +async function cssFromSourceInternal( + source: string, + options: CssFromSourceOptions, +): Promise { + const filename = options.filename ?? defaultFilename + + if (options.dialect === 'css') { + return { ok: true, css: source } + } + + if (options.dialect === 'sass') { + if (!options.sass) { + throw new Error('@knighted/css: Missing Sass compiler for browser usage.') + } + if (typeof options.sass.compileStringAsync === 'function') { + const result = await options.sass.compileStringAsync(source, options.sassOptions) + return { ok: true, css: resolveCssText(result) } + } + if (typeof options.sass.compileString === 'function') { + const result = options.sass.compileString(source, options.sassOptions) + return { ok: true, css: resolveCssText(result) } + } + if (typeof options.sass.compile === 'function') { + const result = options.sass.compile(source, options.sassOptions) + return { ok: true, css: resolveCssText(result) } + } + throw new Error( + '@knighted/css: Sass compiler does not expose compileStringAsync, compileString, or compile APIs.', + ) + } + + if (options.dialect === 'less') { + if (!options.less) { + throw new Error('@knighted/css: Missing Less compiler for browser usage.') + } + const result = await options.less.render(source, options.lessOptions) + return { ok: true, css: result.css } + } + + if (options.dialect === 'module') { + if (!options.lightningcss) { + throw new Error('@knighted/css: Missing Lightning CSS WASM compiler.') + } + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const result = options.lightningcss.transform({ + filename, + code: encoder.encode(source), + cssModules: true, + }) + return { + ok: true, + css: decoder.decode(result.code), + exports: result.exports, + } + } + + return { ok: true, css: source } +} + +export async function cssFromSource( + source: string, + options: CssFromSourceOptions, +): Promise { + try { + return await cssFromSourceInternal(source, options) + } catch (error) { + return toErrorResult(error) + } +} diff --git a/packages/css/test/css.browser.test.ts b/packages/css/test/css.browser.test.ts new file mode 100644 index 0000000..4f16d62 --- /dev/null +++ b/packages/css/test/css.browser.test.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { cssFromSource } from '../src/browser.ts' + +test('cssFromSource returns css for plain dialect', async () => { + const result = await cssFromSource('.demo { color: rebeccapurple; }', { + dialect: 'css', + }) + assert.equal(result.ok, true) + if (!result.ok) { + assert.fail('expected ok result') + } + assert.equal(result.css, '.demo { color: rebeccapurple; }') +}) + +test('cssFromSource uses sass compiler', async () => { + const result = await cssFromSource('$color: red; .demo { color: $color; }', { + dialect: 'sass', + sass: { + compileStringAsync: async source => ({ + css: source.replace(/\$color/g, 'red'), + }), + }, + }) + assert.equal(result.ok, true) + if (!result.ok) { + assert.fail('expected ok result') + } + assert.equal(result.css, 'red: red; .demo { color: red; }') +}) + +test('cssFromSource uses sass compileString fallback', async () => { + const result = await cssFromSource('$color: blue; .demo { color: $color; }', { + dialect: 'sass', + sass: { + compileString: source => ({ + css: { toString: () => source.replace(/\$color/g, 'blue') }, + }), + }, + }) + assert.equal(result.ok, true) + if (!result.ok) { + assert.fail('expected ok result') + } + assert.equal(result.css, 'blue: blue; .demo { color: blue; }') +}) + +test('cssFromSource stringifies non-string sass results', async () => { + const result = await cssFromSource('$color: teal;', { + dialect: 'sass', + sass: { + compile: () => ({ css: {} as unknown as string }), + }, + }) + assert.equal(result.ok, true) + if (!result.ok) { + assert.fail('expected ok result') + } + assert.equal(result.css, '[object Object]') +}) + +test('cssFromSource reports missing sass compiler', async () => { + const result = await cssFromSource('$color: red;', { + dialect: 'sass', + }) + assert.equal(result.ok, false) + if (result.ok) { + assert.fail('expected error result') + } + assert.match(result.error.message, /Missing Sass compiler/i) +}) + +test('cssFromSource reports non-object errors', async () => { + const result = await cssFromSource('$color: red;', { + dialect: 'sass', + sass: { + compile: () => { + throw 'sass boom' + }, + }, + }) + assert.equal(result.ok, false) + if (result.ok) { + assert.fail('expected error result') + } + assert.equal(result.error.message, 'sass boom') +}) + +test('cssFromSource uses less compiler', async () => { + const result = await cssFromSource('@color: #fff; .demo { color: @color; }', { + dialect: 'less', + less: { + render: async source => ({ + css: source.replace(/@color/g, '#fff'), + }), + }, + }) + assert.equal(result.ok, true) + if (!result.ok) { + assert.fail('expected ok result') + } + assert.equal(result.css, '#fff: #fff; .demo { color: #fff; }') +}) + +test('cssFromSource reports missing less compiler', async () => { + const result = await cssFromSource('@color: #fff;', { + dialect: 'less', + }) + assert.equal(result.ok, false) + if (result.ok) { + assert.fail('expected error result') + } + assert.match(result.error.message, /Missing Less compiler/i) +}) + +test('cssFromSource preserves error codes from less compiler', async () => { + const result = await cssFromSource('@color: #fff;', { + dialect: 'less', + less: { + render: async () => { + const error = new Error('less failed') as Error & { code?: string } + error.code = 'LESS_FAIL' + throw error + }, + }, + }) + assert.equal(result.ok, false) + if (result.ok) { + assert.fail('expected error result') + } + assert.equal(result.error.code, 'LESS_FAIL') +}) + +test('cssFromSource uses lightningcss for css modules', async () => { + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const result = await cssFromSource('.demo { color: green; }', { + dialect: 'module', + filename: 'custom.css', + lightningcss: { + transform: ({ code, cssModules, filename }) => { + assert.equal(cssModules, true) + assert.equal(filename, 'custom.css') + const decoded = decoder.decode(code) + return { + code: encoder.encode(decoded.replace('green', 'teal')), + exports: { demo: 'demo_hash' }, + } + }, + }, + }) + assert.equal(result.ok, true) + if (!result.ok) { + assert.fail('expected ok result') + } + assert.equal(result.css, '.demo { color: teal; }') + assert.deepEqual(result.exports, { demo: 'demo_hash' }) +}) + +test('cssFromSource reports missing lightningcss compiler', async () => { + const result = await cssFromSource('.demo { color: green; }', { + dialect: 'module', + }) + assert.equal(result.ok, false) + if (result.ok) { + assert.fail('expected error result') + } + assert.match(result.error.message, /Missing Lightning CSS WASM/i) +}) diff --git a/packages/playwright/browser-entrypoint.html b/packages/playwright/browser-entrypoint.html new file mode 100644 index 0000000..f801d05 --- /dev/null +++ b/packages/playwright/browser-entrypoint.html @@ -0,0 +1,127 @@ + + + + + + Browser entrypoint + + + +
+
+
+
+
+
+ + + diff --git a/packages/playwright/dist-css b/packages/playwright/dist-css new file mode 120000 index 0000000..f14ee33 --- /dev/null +++ b/packages/playwright/dist-css @@ -0,0 +1 @@ +/Users/morgan/knighted/css/packages/css/dist \ No newline at end of file diff --git a/packages/playwright/package.json b/packages/playwright/package.json index fb7882f..63e28d6 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -33,11 +33,11 @@ "clean:knighted": "rm -rf .knighted-*", "serve": "http-server dist -p 4174", "test": "playwright test", - "pretest": "npm run types && npm run build" + "pretest": "npm run types && npm run build && node ./scripts/link-css-dist.js" }, "dependencies": { - "@knighted/css": "1.2.0-rc.1", - "@knighted/jsx": "^1.7.5", + "@knighted/css": "1.2.0-rc.2", + "@knighted/jsx": "^1.7.6", "lit": "^3.2.1", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/packages/playwright/scripts/link-css-dist.js b/packages/playwright/scripts/link-css-dist.js new file mode 100644 index 0000000..95373d0 --- /dev/null +++ b/packages/playwright/scripts/link-css-dist.js @@ -0,0 +1,20 @@ +/* + This symlink exists only for Playwright. The test server serves the + packages/playwright folder as its web root, but the browser entrypoint + for @knighted/css is built into packages/css/dist. The importmap in + browser-entrypoint.html needs a URL the server can see, so we expose + packages/css/dist at /dist-css without copying or bundling. +*/ +import { rm, symlink } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const targetPath = path.resolve(__dirname, '../../css/dist') +const linkPath = path.resolve(__dirname, '../dist-css') + +await rm(linkPath, { recursive: true, force: true }) + +await symlink(targetPath, linkPath, 'junction') diff --git a/packages/playwright/test/browser-entrypoint.spec.ts b/packages/playwright/test/browser-entrypoint.spec.ts new file mode 100644 index 0000000..5a1a694 --- /dev/null +++ b/packages/playwright/test/browser-entrypoint.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test' + +test('loads browser entrypoint via importmap', async ({ page }) => { + await page.goto('/browser-entrypoint.html') + + const cssResult = page.getByTestId('browser-entrypoint-css') + await expect(cssResult).toHaveAttribute('data-ok', 'true') + await expect(cssResult).toBeVisible() + await expect(cssResult).toHaveText('.demo { color: rebeccapurple; }') + + const sassResult = page.getByTestId('browser-entrypoint-sass') + await expect(sassResult).toHaveAttribute('data-ok', 'true') + await expect(sassResult).toBeVisible() + await expect(sassResult).toContainText('.demo') + await expect(sassResult).not.toContainText('$color') + + const lessResult = page.getByTestId('browser-entrypoint-less') + await expect(lessResult).toHaveAttribute('data-ok', 'true') + await expect(lessResult).toBeVisible() + await expect(lessResult).toContainText('.demo') + await expect(lessResult).not.toContainText('@color') + + const moduleResult = page.getByTestId('browser-entrypoint-module') + await expect(moduleResult).toHaveAttribute('data-ok', 'true') + await expect(moduleResult).toBeVisible() + await expect(moduleResult).toHaveAttribute('data-exports', /demo/) +})