diff --git a/packages/dom-css/src/tailwind.ts b/packages/dom-css/src/tailwind.ts index a2649802f..2dd0d529d 100644 --- a/packages/dom-css/src/tailwind.ts +++ b/packages/dom-css/src/tailwind.ts @@ -12,7 +12,7 @@ export async function compileTailwindCSS( classes: string | Iterable, options: CompileTailwindCSSOptions = {} ): Promise { - const compiler = await compile(options.css ?? DEFAULT_TAILWIND_CSS, { + const compiler = await compile(tailwindCompilerCSS(options.css), { base: options.base, loadStylesheet: async (id, base) => { const content = options.loadStylesheet @@ -24,6 +24,20 @@ export async function compileTailwindCSS( return compiler.build(normalizeClasses(classes)) } +function tailwindCompilerCSS(css: string | undefined): string { + const trimmed = css?.trim() + if (!trimmed) return DEFAULT_TAILWIND_CSS + if (containsTailwindImport(trimmed)) return trimmed + return `${DEFAULT_TAILWIND_CSS}\n${trimmed}` +} + +function containsTailwindImport(css: string): boolean { + const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '') + return /^\s*@import\s+(?:"tailwindcss(?:\/[^"]*)?"|'tailwindcss(?:\/[^']*)?')/m.test( + withoutComments + ) +} + function normalizeClasses(classes: string | Iterable): string[] { const classNames = typeof classes === 'string' ? [classes] : Array.from(classes) return classNames diff --git a/packages/dom-css/tests/tailwind.test.ts b/packages/dom-css/tests/tailwind.test.ts index 3a4b15d2f..fa1d487c2 100644 --- a/packages/dom-css/tests/tailwind.test.ts +++ b/packages/dom-css/tests/tailwind.test.ts @@ -18,6 +18,17 @@ describe('@open-pencil/dom-css Tailwind', () => { expect(css).toContain('.rounded-xl') }) + it('combines caller CSS with generated Tailwind utilities', async () => { + const css = await compileTailwindCSS(['w-60', 'p-6'], { + css: '.card { color: red; }' + }) + + expect(css).toContain('.card') + expect(css).toContain('color: red') + expect(css).toContain('.w-60') + expect(css).toContain('.p-6') + }) + it('feeds Tailwind generated CSS through headless style computation', async () => { const runtime = createHeadlessCSSRuntime() const classes = [...tailwindCardClasses] diff --git a/packages/kiwi/src/fig/container.ts b/packages/kiwi/src/fig/container.ts index 5ac08bb10..a299f4656 100644 --- a/packages/kiwi/src/fig/container.ts +++ b/packages/kiwi/src/fig/container.ts @@ -1,7 +1,15 @@ import { deflateSync, inflateSync } from 'fflate' +import { decompress as fzstdDecompressSync } from 'fzstd' + +import { isZstdCompressed } from './protocol' export const FIG_KIWI_DEFAULT_VERSION = 101 +function bunZstdDecompressSync(data: Uint8Array): Uint8Array | undefined { + const g = globalThis as { Bun?: { zstdDecompressSync?: (data: Uint8Array) => Uint8Array } } + return g.Bun?.zstdDecompressSync?.(data) +} + export function parseFigKiwiChunks(binary: Uint8Array): Uint8Array[] | null { const header = new TextDecoder().decode(binary.slice(0, 8)) if (header !== 'fig-kiwi') return null @@ -20,6 +28,12 @@ export function parseFigKiwiChunks(binary: Uint8Array): Uint8Array[] | null { } export function decompressFigKiwiData(compressed: Uint8Array): Uint8Array { + if (isZstdCompressed(compressed)) { + const decompressed = bunZstdDecompressSync(compressed) + if (decompressed) return decompressed + return fzstdDecompressSync(compressed) + } + try { return inflateSync(compressed) } catch { @@ -28,11 +42,15 @@ export function decompressFigKiwiData(compressed: Uint8Array): Uint8Array { } export async function decompressFigKiwiDataAsync(compressed: Uint8Array): Promise { + if (isZstdCompressed(compressed)) { + const bunDecompressed = bunZstdDecompressSync(compressed) + if (bunDecompressed) return bunDecompressed + return fzstdDecompressSync(compressed) + } try { return inflateSync(compressed) } catch { - const fzstd = await import('fzstd') - return fzstd.decompress(compressed) + throw new Error('Failed to decompress fig-kiwi data') } } @@ -41,9 +59,18 @@ export function buildFigKiwi( dataRaw: Uint8Array, version = FIG_KIWI_DEFAULT_VERSION ): Uint8Array { - const dataDeflated = deflateSync(dataRaw) + let dataCompressed: Uint8Array + const zstdCompress: ((data: Uint8Array) => Uint8Array) | undefined = (() => { + const g = globalThis as { Bun?: { zstdCompressSync?: (data: Uint8Array) => Uint8Array } } + return g.Bun?.zstdCompressSync + })() + if (zstdCompress) { + dataCompressed = zstdCompress(dataRaw) + } else { + dataCompressed = deflateSync(dataRaw) + } - const total = 8 + 4 + 4 + schemaDeflated.length + 4 + dataDeflated.length + const total = 8 + 4 + 4 + schemaDeflated.length + 4 + dataCompressed.length const out = new Uint8Array(total) const view = new DataView(out.buffer) @@ -56,9 +83,9 @@ export function buildFigKiwi( out.set(schemaDeflated, offset) offset += schemaDeflated.length - view.setUint32(offset, dataDeflated.length, true) + view.setUint32(offset, dataCompressed.length, true) offset += 4 - out.set(dataDeflated, offset) + out.set(dataCompressed, offset) return out } diff --git a/packages/kiwi/src/fig/parse.ts b/packages/kiwi/src/fig/parse.ts index 1c4c46b78..d12e5cb5c 100644 --- a/packages/kiwi/src/fig/parse.ts +++ b/packages/kiwi/src/fig/parse.ts @@ -74,7 +74,11 @@ export function parseFigKiwiContainer(data: Uint8Array): FigKiwiPayload | null { if (isZstdCompressed(compressed)) { dataRaw = zstdDecompress(compressed) } else { - dataRaw = inflateSync(compressed) + try { + dataRaw = inflateSync(compressed) + } catch { + throw new Error('Failed to decompress fig-kiwi data chunk') + } } return { schemaDeflated: chunks[0], dataRaw, version } diff --git a/packages/kiwi/tests/container.test.ts b/packages/kiwi/tests/container.test.ts index 2147a750a..bfd21c2f9 100644 --- a/packages/kiwi/tests/container.test.ts +++ b/packages/kiwi/tests/container.test.ts @@ -11,7 +11,7 @@ import { } from '../src/fig/container' describe('Figma FIG Kiwi container helpers', () => { - test('builds and parses fig-kiwi chunks', () => { + test('builds and parses fig-kiwi chunks', async () => { const schemaDeflated = new Uint8Array([1, 2, 3]) const dataRaw = new TextEncoder().encode('payload') const binary = buildFigKiwi(schemaDeflated, dataRaw) @@ -20,7 +20,14 @@ describe('Figma FIG Kiwi container helpers', () => { expect(chunks).not.toBeNull() expect(chunks?.[0]).toEqual(schemaDeflated) - expect(chunks?.[1]).toEqual(deflateSync(dataRaw)) + // A well-formed fig-kiwi container always carries a payload chunk; dereference it + // explicitly so a missing second chunk fails the test directly instead of being + // masked by an empty-buffer fallback. + const dataChunk = chunks?.[1] + expect(dataChunk).toBeInstanceOf(Uint8Array) + expect(dataChunk?.length).toBeGreaterThan(0) + expect(decompressFigKiwiData(dataChunk as Uint8Array)).toEqual(dataRaw) + await expect(decompressFigKiwiDataAsync(dataChunk as Uint8Array)).resolves.toEqual(dataRaw) }) test('uses the default container version', () => { diff --git a/tests/engine/io/fig/heavy/component-metadata.test.ts b/tests/engine/io/fig/heavy/component-metadata.test.ts index 9fc4dc880..8ef7ec421 100644 --- a/tests/engine/io/fig/heavy/component-metadata.test.ts +++ b/tests/engine/io/fig/heavy/component-metadata.test.ts @@ -1,16 +1,15 @@ import { expect, setDefaultTimeout, test } from 'bun:test' -import { readFileSync } from 'node:fs' import { parseFigBuffer } from '@open-pencil/fig' import { importNodeChanges } from '#core/kiwi' import { expectDefined } from '#tests/helpers/assert' +import { readFixtureArrayBuffer } from '#tests/helpers/fig-fixtures' import { heavy } from '#tests/helpers/test-utils' function importFixture(name: string) { - const buffer = readFileSync(`tests/fixtures/${name}`).buffer - const { nodeChanges, blobs, images } = parseFigBuffer(buffer) + const { nodeChanges, blobs, images } = parseFigBuffer(readFixtureArrayBuffer(name)) return importNodeChanges(nodeChanges, blobs, new Map(images)) } diff --git a/tests/helpers/fig-fixtures.ts b/tests/helpers/fig-fixtures.ts index 8823df098..28ae45233 100644 --- a/tests/helpers/fig-fixtures.ts +++ b/tests/helpers/fig-fixtures.ts @@ -1,8 +1,12 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { parseFigFile } from '@open-pencil/core' -import type { ParseFigFileOptions, SceneGraph, SceneNode } from '@open-pencil/core' +import { + parseFigFile, + type ParseFigFileOptions, + type SceneGraph, + type SceneNode +} from '@open-pencil/core' import { collectAllNodes } from './fig-traversal' @@ -33,12 +37,26 @@ export function readFixtureBytes(name: string): Uint8Array { return readFileSync(resolve(FIXTURES, name)) } +export function uint8ArrayToArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buffer = bytes.buffer + if (buffer instanceof ArrayBuffer) { + return buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + } + + const copy = new Uint8Array(bytes.byteLength) + copy.set(bytes) + return copy.buffer +} + +export function readFixtureArrayBuffer(name: string): ArrayBuffer { + return uint8ArrayToArrayBuffer(readFixtureBytes(name)) +} + export async function parseFixture( name: string, options?: ParseFigFileOptions ): Promise { - const bytes = readFixtureBytes(name) - return parseFigFile(bytes.buffer as ArrayBuffer, options) + return parseFigFile(readFixtureArrayBuffer(name), options) } export async function parseGoldPreviewFixture(): Promise<{