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:
parent
9676bfe31b
commit
c7b944d103
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
Loading…
Reference in a new issue