fix(kiwi): harden FIG containers and DOM imports

- Decode zstd FIG data and reject invalid compressed payloads

- Compose caller CSS with Tailwind defaults during DOM import

- Slice pooled fixture buffers to their exact byte range

Co-authored-by: Joseph Cumines <joeycumines@gmail.com>
This commit is contained in:
Danila Poyarkov 2026-08-13 18:31:08 +03:00
parent 9676bfe31b
commit c7b944d103
7 changed files with 97 additions and 17 deletions

View file

@ -12,7 +12,7 @@ export async function compileTailwindCSS(
classes: string | Iterable<string>,
options: CompileTailwindCSSOptions = {}
): Promise<string> {
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>): string[] {
const classNames = typeof classes === 'string' ? [classes] : Array.from(classes)
return classNames

View file

@ -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]

View file

@ -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<Uint8Array> {
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
}

View file

@ -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 }

View file

@ -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', () => {

View file

@ -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))
}

View file

@ -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<SceneGraph> {
const bytes = readFixtureBytes(name)
return parseFigFile(bytes.buffer as ArrayBuffer, options)
return parseFigFile(readFixtureArrayBuffer(name), options)
}
export async function parseGoldPreviewFixture(): Promise<{