refactor(kiwi): consume standalone Kiwi package
This commit is contained in:
parent
f5bd2425b6
commit
6877516f06
2
bun.lock
2
bun.lock
|
|
@ -17,6 +17,7 @@
|
|||
"@open-pencil/cli": "workspace:*",
|
||||
"@open-pencil/core": "workspace:*",
|
||||
"@open-pencil/dom-css": "workspace:*",
|
||||
"@open-pencil/kiwi": "workspace:*",
|
||||
"@open-pencil/vue": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "^2.9.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
|
@ -120,6 +121,7 @@
|
|||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.7",
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"@open-pencil/kiwi": "workspace:*",
|
||||
"@tauri-apps/api": "^2",
|
||||
"acorn": "^8.16.0",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
"@open-pencil/cli": "workspace:*",
|
||||
"@open-pencil/core": "workspace:*",
|
||||
"@open-pencil/dom-css": "workspace:*",
|
||||
"@open-pencil/kiwi": "workspace:*",
|
||||
"@open-pencil/vue": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "^2.9.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@
|
|||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.7",
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"@open-pencil/kiwi": "workspace:*",
|
||||
"@tauri-apps/api": "^2",
|
||||
"acorn": "^8.16.0",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { inflateSync, deflateSync } from 'fflate'
|
||||
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime'
|
||||
|
||||
import { shapeTextForClipboard } from './canvas/text'
|
||||
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/fig/codec'
|
||||
import type { NodeChange as KiwiNodeChange } from './kiwi/fig/codec'
|
||||
|
|
@ -15,7 +17,6 @@ import {
|
|||
makeCanvasNodeChange,
|
||||
buildFontDigestMap
|
||||
} from './kiwi/fig/node-change/serialize'
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi/schema-runtime'
|
||||
import { randomInt } from './random'
|
||||
import type { SceneGraph, SceneNode } from './scene-graph'
|
||||
import { buildDerivedTextDataV4 } from './text/derived-text/clipboard'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { CanvasKit } from 'canvaskit-wasm'
|
||||
import { deflateSync, inflateSync } from 'fflate'
|
||||
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime'
|
||||
|
||||
import type { SkiaRenderer } from '#core/canvas'
|
||||
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from '#core/constants'
|
||||
import { renderThumbnail } from '#core/io/formats/raster'
|
||||
|
|
@ -16,7 +18,6 @@ import {
|
|||
makeDocumentNodeChange,
|
||||
makeCanvasNodeChange
|
||||
} from '#core/kiwi/fig/node-change/serialize'
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from '#core/kiwi/schema-runtime'
|
||||
import type { SceneGraph, VariableValue } from '#core/scene-graph'
|
||||
import type { GUID } from '#core/types'
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@
|
|||
|
||||
import { decompress as zstdDecompress } from 'fzstd'
|
||||
|
||||
import { parseColor } from '#core/color'
|
||||
import { compileSchema, encodeBinarySchema } from '#core/kiwi/schema-runtime'
|
||||
import { figmaSchema, isZstdCompressed, getKiwiMessageType } from '@open-pencil/kiwi/fig'
|
||||
import { compileSchema, encodeBinarySchema } from '@open-pencil/kiwi/schema-runtime'
|
||||
|
||||
import { parseColor } from '#core/color'
|
||||
|
||||
import { isZstdCompressed, getKiwiMessageType } from './protocol'
|
||||
import figmaSchema from './schema'
|
||||
import * as VariableBindings from './variable-bindings'
|
||||
|
||||
interface CompiledSchema {
|
||||
|
|
|
|||
|
|
@ -1,237 +0,0 @@
|
|||
/**
|
||||
* Figma Multiplayer Protocol
|
||||
*
|
||||
* This module handles the low-level WebSocket communication with Figma's
|
||||
* multiplayer server. The protocol uses:
|
||||
*
|
||||
* - Kiwi binary serialization (schema-based, like Protocol Buffers)
|
||||
* - Zstd compression for all messages
|
||||
* - Session-based authentication via cookies
|
||||
*
|
||||
* Message types (from Figma's schema):
|
||||
* 0 = JOIN_START - Server sends session info
|
||||
* 1 = NODE_CHANGES - Create/update/delete nodes
|
||||
* 2 = USER_CHANGES - User presence updates
|
||||
* 3 = JOIN_END - Initial sync complete
|
||||
* 4 = SIGNAL - Various metadata (reconnect info, etc.)
|
||||
* 5 = STYLE - Style updates
|
||||
* ...and more
|
||||
*
|
||||
* Wire format:
|
||||
* All messages are Zstd-compressed Kiwi-encoded binary data.
|
||||
* Zstd magic bytes: 0x28 0xB5 0x2F 0xFD
|
||||
*/
|
||||
|
||||
export const MESSAGE_TYPES = {
|
||||
JOIN_START: 0,
|
||||
NODE_CHANGES: 1,
|
||||
USER_CHANGES: 2,
|
||||
JOIN_END: 3,
|
||||
SIGNAL: 4,
|
||||
STYLE: 5,
|
||||
STYLE_SET: 6,
|
||||
JOIN_START_SKIP_RELOAD: 7,
|
||||
NOTIFY_SHOULD_UPGRADE: 8,
|
||||
UPGRADE_DONE: 9,
|
||||
UPGRADE_REFRESH: 10,
|
||||
SCENE_GRAPH_QUERY: 11,
|
||||
SCENE_GRAPH_REPLY: 12,
|
||||
DIFF: 13,
|
||||
CLIENT_BROADCAST: 14
|
||||
} as const
|
||||
|
||||
export const NODE_TYPES = {
|
||||
NONE: 0,
|
||||
DOCUMENT: 1,
|
||||
CANVAS: 2,
|
||||
GROUP: 3,
|
||||
FRAME: 4,
|
||||
BOOLEAN_OPERATION: 5,
|
||||
VECTOR: 6,
|
||||
STAR: 7,
|
||||
LINE: 8,
|
||||
ELLIPSE: 9,
|
||||
RECTANGLE: 10,
|
||||
REGULAR_POLYGON: 11,
|
||||
ROUNDED_RECTANGLE: 12,
|
||||
TEXT: 13,
|
||||
SLICE: 14,
|
||||
SYMBOL: 15,
|
||||
INSTANCE: 16,
|
||||
STICKY: 17,
|
||||
SHAPE_WITH_TEXT: 18,
|
||||
CONNECTOR: 19,
|
||||
CODE_BLOCK: 20,
|
||||
WIDGET: 21,
|
||||
STAMP: 22,
|
||||
MEDIA: 23,
|
||||
HIGHLIGHT: 24,
|
||||
SECTION: 25,
|
||||
SECTION_OVERLAY: 26,
|
||||
WASHI_TAPE: 27,
|
||||
VARIABLE: 28
|
||||
} as const
|
||||
|
||||
export const NODE_PHASES = {
|
||||
CREATED: 0,
|
||||
REMOVED: 1
|
||||
} as const
|
||||
|
||||
export const BLEND_MODES = {
|
||||
PASS_THROUGH: 0,
|
||||
NORMAL: 1,
|
||||
DARKEN: 2,
|
||||
MULTIPLY: 3,
|
||||
LINEAR_BURN: 4,
|
||||
COLOR_BURN: 5,
|
||||
LIGHTEN: 6,
|
||||
SCREEN: 7,
|
||||
LINEAR_DODGE: 8,
|
||||
COLOR_DODGE: 9,
|
||||
OVERLAY: 10,
|
||||
SOFT_LIGHT: 11,
|
||||
HARD_LIGHT: 12,
|
||||
DIFFERENCE: 13,
|
||||
EXCLUSION: 14,
|
||||
HUE: 15,
|
||||
SATURATION: 16,
|
||||
COLOR: 17,
|
||||
LUMINOSITY: 18
|
||||
} as const
|
||||
|
||||
export const PAINT_TYPES = {
|
||||
SOLID: 0,
|
||||
GRADIENT_LINEAR: 1,
|
||||
GRADIENT_RADIAL: 2,
|
||||
GRADIENT_ANGULAR: 3,
|
||||
GRADIENT_DIAMOND: 4,
|
||||
IMAGE: 5,
|
||||
EMOJI: 6,
|
||||
VIDEO: 7
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Zstd magic bytes
|
||||
*/
|
||||
export const ZSTD_MAGIC = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd])
|
||||
|
||||
// ============================================================================
|
||||
// Kiwi Binary Format Constants
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Kiwi uses field numbers to identify message fields.
|
||||
* Field 1 with value = message type indicates the message kind.
|
||||
*/
|
||||
export const KIWI = {
|
||||
/** First byte of valid Kiwi messages (field number 1) */
|
||||
MESSAGE_MARKER: 1,
|
||||
|
||||
/** Field number for sessionID in JOIN_START message */
|
||||
SESSION_ID_FIELD: 2,
|
||||
|
||||
/** Varint continuation bit (MSB set = more bytes follow) */
|
||||
VARINT_CONTINUE_BIT: 0x80,
|
||||
|
||||
/** Varint value mask (lower 7 bits contain data) */
|
||||
VARINT_VALUE_MASK: 0x7f,
|
||||
|
||||
/** Bits per varint byte */
|
||||
VARINT_BITS_PER_BYTE: 7
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Valid session ID range (based on observed Figma behavior)
|
||||
*/
|
||||
export const SESSION_ID = {
|
||||
MIN: 10000,
|
||||
MAX: 1000000
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parse a varint from a Uint8Array at given position
|
||||
* Returns [value, newPosition]
|
||||
*/
|
||||
export function parseVarint(data: Uint8Array, pos: number): [number, number] {
|
||||
let value = 0
|
||||
let shift = 0
|
||||
|
||||
while (pos < data.length) {
|
||||
const byte = data[pos]
|
||||
pos++
|
||||
value |= (byte & KIWI.VARINT_VALUE_MASK) << shift
|
||||
|
||||
if (!(byte & KIWI.VARINT_CONTINUE_BIT)) {
|
||||
break
|
||||
}
|
||||
shift += KIWI.VARINT_BITS_PER_BYTE
|
||||
}
|
||||
|
||||
return [value, pos]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data is a valid Kiwi message
|
||||
*/
|
||||
export function isKiwiMessage(data: Uint8Array): boolean {
|
||||
return data.length >= 2 && data[0] === KIWI.MESSAGE_MARKER
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message type from Kiwi message
|
||||
*/
|
||||
export function getKiwiMessageType(data: Uint8Array): number | null {
|
||||
if (!isKiwiMessage(data)) return null
|
||||
return data[1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* fig-wire header magic (first 8 bytes of some messages)
|
||||
*/
|
||||
export const FIG_WIRE_MAGIC = 'fig-wire'
|
||||
|
||||
/**
|
||||
* Check if data is Zstd-compressed
|
||||
*/
|
||||
export function isZstdCompressed(data: Uint8Array): boolean {
|
||||
return (
|
||||
data.length >= 4 && data[0] === 0x28 && data[1] === 0xb5 && data[2] === 0x2f && data[3] === 0xfd
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data has fig-wire header
|
||||
*/
|
||||
export function hasFigWireHeader(data: Uint8Array): boolean {
|
||||
if (data.length < 8) return false
|
||||
const header = new TextDecoder().decode(data.slice(0, 8))
|
||||
return header === FIG_WIRE_MAGIC
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip fig-wire header and find zstd data
|
||||
* Header format: "fig-wire" (8 bytes) + version (4 bytes LE) + zstd data
|
||||
*/
|
||||
export function skipFigWireHeader(data: Uint8Array): Uint8Array {
|
||||
if (!hasFigWireHeader(data)) return data
|
||||
// Skip 8 bytes header + 4 bytes version
|
||||
return data.slice(12)
|
||||
}
|
||||
|
||||
/**
|
||||
* Current multiplayer protocol version
|
||||
*/
|
||||
export const PROTOCOL_VERSION = 151
|
||||
|
||||
/**
|
||||
* Build WebSocket URL for Figma multiplayer
|
||||
*/
|
||||
export function buildMultiplayerUrl(fileKey: string, trackingId?: string): string {
|
||||
const params = new URLSearchParams({
|
||||
role: 'editor',
|
||||
version: String(PROTOCOL_VERSION),
|
||||
recentReload: '0',
|
||||
tracking_session_id: trackingId || `ws-${Date.now()}`
|
||||
})
|
||||
return `wss://www.figma.com/api/multiplayer/${fileKey}?${params}`
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +0,0 @@
|
|||
import { parseSchema, validateSchema } from '#core/kiwi/schema-runtime'
|
||||
|
||||
import schemaText from './fig.kiwi?raw'
|
||||
|
||||
const schema = parseSchema(schemaText)
|
||||
validateSchema(schema)
|
||||
|
||||
export default schema
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { unzipSync, inflateSync } from 'fflate'
|
||||
import { decompress as zstdDecompress } from 'fzstd'
|
||||
|
||||
import { isZstdCompressed } from '@open-pencil/kiwi/fig'
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime'
|
||||
|
||||
import type { FigmaMessage, NodeChange } from '#core/kiwi/fig/codec'
|
||||
import { isZstdCompressed } from '#core/kiwi/fig/codec/protocol'
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from '#core/kiwi/schema-runtime'
|
||||
|
||||
/**
|
||||
* Deduplicates pluginData/pluginRelaunchData entries on raw NodeChange objects.
|
||||
|
|
|
|||
|
|
@ -48,4 +48,4 @@ export {
|
|||
getKiwiMessageType,
|
||||
parseVarint,
|
||||
FIG_WIRE_MAGIC
|
||||
} from './fig/codec/protocol'
|
||||
} from '@open-pencil/kiwi/fig'
|
||||
|
|
|
|||
|
|
@ -1,252 +0,0 @@
|
|||
let int32 = new Int32Array(1)
|
||||
let float32 = new Float32Array(int32.buffer)
|
||||
const textDecoder = new TextDecoder()
|
||||
|
||||
export class ByteBuffer {
|
||||
private _data: Uint8Array
|
||||
private _index: number
|
||||
length: number
|
||||
|
||||
constructor(data?: Uint8Array) {
|
||||
if (data && !(data instanceof Uint8Array)) {
|
||||
throw new Error('Must initialize a ByteBuffer with a Uint8Array')
|
||||
}
|
||||
this._data = data || new Uint8Array(256)
|
||||
this._index = 0
|
||||
this.length = data ? data.length : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a view into the internal buffer, not a copy.
|
||||
*
|
||||
* Consumers transferring this Uint8Array across thread boundaries (e.g. via
|
||||
* postMessage with transferables) MUST copy first: `new Uint8Array(buffer)`.
|
||||
* Otherwise, if multiple Uint8Arrays share the same underlying ArrayBuffer and
|
||||
* one is transferred, all views into that buffer become detached.
|
||||
*/
|
||||
toUint8Array(): Uint8Array {
|
||||
return this._data.subarray(0, this.length)
|
||||
}
|
||||
|
||||
readByte(): number {
|
||||
return this._data[this._index++]
|
||||
}
|
||||
|
||||
readByteArray(): Uint8Array {
|
||||
const length = this.readVarUint()
|
||||
const start = this._index
|
||||
this._index = start + length
|
||||
return this._data.slice(start, start + length)
|
||||
}
|
||||
|
||||
readVarFloat(): number {
|
||||
const index = this._index
|
||||
const data = this._data
|
||||
const first = data[index]
|
||||
if (first === 0) {
|
||||
this._index = index + 1
|
||||
return 0
|
||||
}
|
||||
|
||||
let bits = first | (data[index + 1] << 8) | (data[index + 2] << 16) | (data[index + 3] << 24)
|
||||
this._index = index + 4
|
||||
bits = (bits << 23) | (bits >>> 9)
|
||||
int32[0] = bits
|
||||
return float32[0]
|
||||
}
|
||||
|
||||
readVarUint(): number {
|
||||
const data = this._data
|
||||
let i = this._index
|
||||
let b = data[i++]
|
||||
let value = b & 127
|
||||
if (b < 128) {
|
||||
this._index = i
|
||||
return value
|
||||
}
|
||||
b = data[i++]
|
||||
value |= (b & 127) << 7
|
||||
if (b < 128) {
|
||||
this._index = i
|
||||
return value
|
||||
}
|
||||
b = data[i++]
|
||||
value |= (b & 127) << 14
|
||||
if (b < 128) {
|
||||
this._index = i
|
||||
return value
|
||||
}
|
||||
b = data[i++]
|
||||
value |= (b & 127) << 21
|
||||
if (b < 128) {
|
||||
this._index = i
|
||||
return value
|
||||
}
|
||||
b = data[i++]
|
||||
value |= (b & 127) << 28
|
||||
this._index = i
|
||||
return value >>> 0
|
||||
}
|
||||
|
||||
readVarInt(): number {
|
||||
let value = this.readVarUint() | 0
|
||||
return value & 1 ? ~(value >>> 1) : value >>> 1
|
||||
}
|
||||
|
||||
readVarUint64(): bigint {
|
||||
let value = BigInt(0)
|
||||
let shift = BigInt(0)
|
||||
let seven = BigInt(7)
|
||||
let byte: number
|
||||
while ((byte = this.readByte()) & 128 && shift < 56) {
|
||||
value |= BigInt(byte & 127) << shift
|
||||
shift += seven
|
||||
}
|
||||
value |= BigInt(byte) << shift
|
||||
return value
|
||||
}
|
||||
|
||||
readVarInt64(): bigint {
|
||||
let value = this.readVarUint64()
|
||||
let one = BigInt(1)
|
||||
let sign = value & one
|
||||
value >>= one
|
||||
return sign ? ~value : value
|
||||
}
|
||||
|
||||
readString(): string {
|
||||
const data = this._data
|
||||
const start = this._index
|
||||
let i = start
|
||||
while (data[i] !== 0) i++
|
||||
this._index = i + 1
|
||||
return textDecoder.decode(data.subarray(start, i))
|
||||
}
|
||||
|
||||
private _growBy(amount: number): void {
|
||||
if (this.length + amount > this._data.length) {
|
||||
let data = new Uint8Array((this.length + amount) << 1)
|
||||
data.set(this._data)
|
||||
this._data = data
|
||||
}
|
||||
this.length += amount
|
||||
}
|
||||
|
||||
writeByte(value: number): void {
|
||||
let index = this.length
|
||||
this._growBy(1)
|
||||
this._data[index] = value
|
||||
}
|
||||
|
||||
writeByteArray(value: Uint8Array): void {
|
||||
this.writeVarUint(value.length)
|
||||
let index = this.length
|
||||
this._growBy(value.length)
|
||||
this._data.set(value, index)
|
||||
}
|
||||
|
||||
writeVarFloat(value: number): void {
|
||||
let index = this.length
|
||||
|
||||
// Reinterpret as an integer
|
||||
float32[0] = value
|
||||
let bits = int32[0]
|
||||
|
||||
// Move the exponent to the first 8 bits
|
||||
bits = (bits >>> 23) | (bits << 9)
|
||||
|
||||
// Optimization: use a single byte to store zero and denormals (check for an exponent of 0)
|
||||
if ((bits & 255) === 0) {
|
||||
this.writeByte(0)
|
||||
return
|
||||
}
|
||||
|
||||
// Endian-independent 32-bit write
|
||||
this._growBy(4)
|
||||
let data = this._data
|
||||
data[index] = bits
|
||||
data[index + 1] = bits >> 8
|
||||
data[index + 2] = bits >> 16
|
||||
data[index + 3] = bits >> 24
|
||||
}
|
||||
|
||||
writeVarUint(value: number): void {
|
||||
if (value < 0 || value > 0xffff_ffff) throw new Error('Outside uint range: ' + value)
|
||||
do {
|
||||
let byte = value & 127
|
||||
value >>>= 7
|
||||
this.writeByte(value ? byte | 128 : byte)
|
||||
} while (value)
|
||||
}
|
||||
|
||||
writeVarInt(value: number): void {
|
||||
if (value < -0x8000_0000 || value > 0x7fff_ffff) throw new Error('Outside int range: ' + value)
|
||||
this.writeVarUint(((value << 1) ^ (value >> 31)) >>> 0)
|
||||
}
|
||||
|
||||
writeVarUint64(value: bigint | string): void {
|
||||
if (typeof value === 'string') value = BigInt(value)
|
||||
else if (typeof value !== 'bigint')
|
||||
throw new Error(`Expected bigint but got ${typeof value}: ${String(value)}`)
|
||||
if (value < 0 || value > BigInt('0xFFFFFFFFFFFFFFFF'))
|
||||
throw new Error('Outside uint64 range: ' + value)
|
||||
let mask = BigInt(127)
|
||||
let seven = BigInt(7)
|
||||
for (let i = 0; value > mask && i < 8; i++) {
|
||||
this.writeByte(Number(value & mask) | 128)
|
||||
value >>= seven
|
||||
}
|
||||
this.writeByte(Number(value))
|
||||
}
|
||||
|
||||
writeVarInt64(value: bigint | string): void {
|
||||
if (typeof value === 'string') value = BigInt(value)
|
||||
else if (typeof value !== 'bigint')
|
||||
throw new Error(`Expected bigint but got ${typeof value}: ${String(value)}`)
|
||||
if (value < -BigInt('0x8000000000000000') || value > BigInt('0x7FFFFFFFFFFFFFFF'))
|
||||
throw new Error('Outside int64 range: ' + value)
|
||||
let one = BigInt(1)
|
||||
this.writeVarUint64(value < 0 ? ~(value << one) : value << one)
|
||||
}
|
||||
|
||||
writeString(value: string): void {
|
||||
let codePoint
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
// Decode UTF-16
|
||||
let a = value.charCodeAt(i)
|
||||
if (i + 1 === value.length || a < 0xd800 || a >= 0xdc00) {
|
||||
codePoint = a
|
||||
} else {
|
||||
let b = value.charCodeAt(++i)
|
||||
codePoint = (a << 10) + b + (0x10000 - (0xd800 << 10) - 0xdc00)
|
||||
}
|
||||
|
||||
// Strings are null-terminated
|
||||
if (codePoint === 0) {
|
||||
throw new Error('Cannot encode a string containing the null character')
|
||||
}
|
||||
|
||||
// Encode UTF-8
|
||||
if (codePoint < 0x80) {
|
||||
this.writeByte(codePoint)
|
||||
} else {
|
||||
if (codePoint < 0x800) {
|
||||
this.writeByte(((codePoint >> 6) & 0x1f) | 0xc0)
|
||||
} else {
|
||||
if (codePoint < 0x10000) {
|
||||
this.writeByte(((codePoint >> 12) & 0x0f) | 0xe0)
|
||||
} else {
|
||||
this.writeByte(((codePoint >> 18) & 0x07) | 0xf0)
|
||||
this.writeByte(((codePoint >> 12) & 0x3f) | 0x80)
|
||||
}
|
||||
this.writeByte(((codePoint >> 6) & 0x3f) | 0x80)
|
||||
}
|
||||
this.writeByte((codePoint & 0x3f) | 0x80)
|
||||
}
|
||||
}
|
||||
|
||||
// Strings are null-terminated
|
||||
this.writeByte(0)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import { ByteBuffer } from './bb'
|
||||
import { Schema, Field, Definition, DefinitionKind } from './schema'
|
||||
|
||||
let types: (string | null)[] = ['bool', 'byte', 'int', 'uint', 'float', 'string', 'int64', 'uint64']
|
||||
let kinds: DefinitionKind[] = ['ENUM', 'STRUCT', 'MESSAGE']
|
||||
|
||||
export function decodeBinarySchema(buffer: Uint8Array | ByteBuffer): Schema {
|
||||
let bb = buffer instanceof ByteBuffer ? buffer : new ByteBuffer(buffer)
|
||||
let definitionCount = bb.readVarUint()
|
||||
let definitions: Definition[] = []
|
||||
|
||||
// Read in the schema
|
||||
for (let i = 0; i < definitionCount; i++) {
|
||||
let definitionName = bb.readString()
|
||||
let kind = bb.readByte()
|
||||
let fieldCount = bb.readVarUint()
|
||||
let fields: Field[] = []
|
||||
|
||||
for (let j = 0; j < fieldCount; j++) {
|
||||
let fieldName = bb.readString()
|
||||
let type = bb.readVarInt()
|
||||
let isArray = !!(bb.readByte() & 1)
|
||||
let value = bb.readVarUint()
|
||||
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
line: 0,
|
||||
column: 0,
|
||||
type: kinds[kind] === 'ENUM' ? null : (type as any),
|
||||
isArray: isArray,
|
||||
isDeprecated: false,
|
||||
value: value
|
||||
})
|
||||
}
|
||||
|
||||
definitions.push({
|
||||
name: definitionName,
|
||||
line: 0,
|
||||
column: 0,
|
||||
kind: kinds[kind],
|
||||
fields: fields
|
||||
})
|
||||
}
|
||||
|
||||
// Bind type names afterwards
|
||||
for (let i = 0; i < definitionCount; i++) {
|
||||
let fields = definitions[i].fields
|
||||
for (let j = 0; j < fields.length; j++) {
|
||||
let field = fields[j]
|
||||
let type = field.type as any as number | null
|
||||
|
||||
if (type !== null && type < 0) {
|
||||
if (~type >= types.length) {
|
||||
throw new Error('Invalid type ' + type)
|
||||
}
|
||||
field.type = types[~type]
|
||||
} else {
|
||||
if (type !== null && type >= definitions.length) {
|
||||
throw new Error('Invalid type ' + type)
|
||||
}
|
||||
field.type = type === null ? null : definitions[type].name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
package: null,
|
||||
definitions: definitions
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeBinarySchema(schema: Schema): Uint8Array {
|
||||
let bb = new ByteBuffer()
|
||||
let definitions = schema.definitions
|
||||
let definitionIndex: { [name: string]: number } = {}
|
||||
|
||||
bb.writeVarUint(definitions.length)
|
||||
|
||||
for (let i = 0; i < definitions.length; i++) {
|
||||
definitionIndex[definitions[i].name] = i
|
||||
}
|
||||
|
||||
for (let i = 0; i < definitions.length; i++) {
|
||||
let definition = definitions[i]
|
||||
|
||||
bb.writeString(definition.name)
|
||||
bb.writeByte(kinds.indexOf(definition.kind))
|
||||
bb.writeVarUint(definition.fields.length)
|
||||
|
||||
for (let j = 0; j < definition.fields.length; j++) {
|
||||
let field = definition.fields[j]
|
||||
let type = types.indexOf(field.type)
|
||||
|
||||
bb.writeString(field.name)
|
||||
bb.writeVarInt(type === -1 ? definitionIndex[field.type!] : ~type)
|
||||
bb.writeByte(field.isArray ? 1 : 0)
|
||||
bb.writeVarUint(field.value)
|
||||
}
|
||||
}
|
||||
|
||||
return bb.toUint8Array()
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
export type { Schema, Definition, Field } from './schema'
|
||||
export { ByteBuffer } from './bb'
|
||||
export { compileSchema } from './js'
|
||||
export { decodeBinarySchema, encodeBinarySchema } from './binary'
|
||||
export { parseSchema } from './parser'
|
||||
export {
|
||||
validateSchema,
|
||||
expectFieldNumber,
|
||||
expectEnumValue,
|
||||
findDefinition,
|
||||
findField
|
||||
} from './validate'
|
||||
|
|
@ -1,339 +0,0 @@
|
|||
import { ByteBuffer } from './bb'
|
||||
import { Schema, Definition } from './schema'
|
||||
import { error, quote } from './util'
|
||||
|
||||
function compileDecode(
|
||||
definition: Definition,
|
||||
definitions: { [name: string]: Definition }
|
||||
): string {
|
||||
let lines: string[] = []
|
||||
let indent = ' '
|
||||
|
||||
lines.push('function (bb) {')
|
||||
lines.push(' var result = {};')
|
||||
lines.push(' if (!(bb instanceof this.ByteBuffer)) {')
|
||||
lines.push(' bb = new this.ByteBuffer(bb);')
|
||||
lines.push(' }')
|
||||
lines.push('')
|
||||
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' while (true) {')
|
||||
lines.push(' switch (bb.readVarUint()) {')
|
||||
lines.push(' case 0:')
|
||||
lines.push(' return result;')
|
||||
lines.push('')
|
||||
indent = ' '
|
||||
}
|
||||
|
||||
for (let i = 0; i < definition.fields.length; i++) {
|
||||
let field = definition.fields[i]
|
||||
let code: string
|
||||
|
||||
switch (field.type) {
|
||||
case 'bool': {
|
||||
code = '!!bb.readByte()'
|
||||
break
|
||||
}
|
||||
|
||||
case 'byte': {
|
||||
code = 'bb.readByte()' // only used if not array
|
||||
break
|
||||
}
|
||||
|
||||
case 'int': {
|
||||
code = 'bb.readVarInt()'
|
||||
break
|
||||
}
|
||||
|
||||
case 'uint': {
|
||||
code = 'bb.readVarUint()'
|
||||
break
|
||||
}
|
||||
|
||||
case 'float': {
|
||||
code = 'bb.readVarFloat()'
|
||||
break
|
||||
}
|
||||
|
||||
case 'string': {
|
||||
code = 'bb.readString()'
|
||||
break
|
||||
}
|
||||
|
||||
case 'int64': {
|
||||
code = 'bb.readVarInt64()'
|
||||
break
|
||||
}
|
||||
|
||||
case 'uint64': {
|
||||
code = 'bb.readVarUint64()'
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
let type = definitions[field.type!]
|
||||
if (!type) {
|
||||
error(
|
||||
'Invalid type ' + quote(field.type!) + ' for field ' + quote(field.name),
|
||||
field.line,
|
||||
field.column
|
||||
)
|
||||
} else if (type.kind === 'ENUM') {
|
||||
code = 'this[' + quote(type.name) + '][bb.readVarUint()]'
|
||||
} else {
|
||||
code = 'this[' + quote('decode' + type.name) + '](bb)'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' case ' + field.value + ':')
|
||||
}
|
||||
|
||||
if (field.isArray) {
|
||||
if (field.isDeprecated) {
|
||||
if (field.type === 'byte') {
|
||||
lines.push(indent + 'bb.readByteArray();')
|
||||
} else {
|
||||
lines.push(indent + 'var length = bb.readVarUint();')
|
||||
lines.push(indent + 'while (length-- > 0) ' + code + ';')
|
||||
}
|
||||
} else {
|
||||
if (field.type === 'byte') {
|
||||
lines.push(indent + 'result[' + quote(field.name) + '] = bb.readByteArray();')
|
||||
} else {
|
||||
lines.push(indent + 'var length = bb.readVarUint();')
|
||||
lines.push(indent + 'var values = result[' + quote(field.name) + '] = Array(length);')
|
||||
lines.push(indent + 'for (var i = 0; i < length; i++) values[i] = ' + code + ';')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (field.isDeprecated) {
|
||||
lines.push(indent + code + ';')
|
||||
} else {
|
||||
lines.push(indent + 'result[' + quote(field.name) + '] = ' + code + ';')
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' break;')
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' default:')
|
||||
lines.push(' throw new Error("Attempted to parse invalid message");')
|
||||
lines.push(' }')
|
||||
lines.push(' }')
|
||||
} else {
|
||||
lines.push(' return result;')
|
||||
}
|
||||
|
||||
lines.push('}')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function compileEncode(
|
||||
definition: Definition,
|
||||
definitions: { [name: string]: Definition }
|
||||
): string {
|
||||
let lines: string[] = []
|
||||
|
||||
lines.push('function (message, bb) {')
|
||||
lines.push(' var isTopLevel = !bb;')
|
||||
lines.push(' if (isTopLevel) bb = new this.ByteBuffer();')
|
||||
|
||||
for (let j = 0; j < definition.fields.length; j++) {
|
||||
let field = definition.fields[j]
|
||||
let code: string
|
||||
|
||||
if (field.isDeprecated) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'bool': {
|
||||
code = 'bb.writeByte(value);'
|
||||
break
|
||||
}
|
||||
|
||||
case 'byte': {
|
||||
code = 'bb.writeByte(value);' // only used if not array
|
||||
break
|
||||
}
|
||||
|
||||
case 'int': {
|
||||
code = 'bb.writeVarInt(value);'
|
||||
break
|
||||
}
|
||||
|
||||
case 'uint': {
|
||||
code = 'bb.writeVarUint(value);'
|
||||
break
|
||||
}
|
||||
|
||||
case 'float': {
|
||||
code = 'bb.writeVarFloat(value);'
|
||||
break
|
||||
}
|
||||
|
||||
case 'string': {
|
||||
code = 'bb.writeString(value);'
|
||||
break
|
||||
}
|
||||
|
||||
case 'int64': {
|
||||
code = 'bb.writeVarInt64(value);'
|
||||
break
|
||||
}
|
||||
|
||||
case 'uint64': {
|
||||
code = 'bb.writeVarUint64(value);'
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
let type = definitions[field.type!]
|
||||
if (!type) {
|
||||
throw new Error('Invalid type ' + quote(field.type!) + ' for field ' + quote(field.name))
|
||||
} else if (type.kind === 'ENUM') {
|
||||
code =
|
||||
'var encoded = this[' +
|
||||
quote(type.name) +
|
||||
'][value]; ' +
|
||||
'if (encoded === void 0) throw new Error("Invalid value " + JSON.stringify(value) + ' +
|
||||
quote(' for enum ' + quote(type.name)) +
|
||||
'); ' +
|
||||
'bb.writeVarUint(encoded);'
|
||||
} else {
|
||||
code = 'this[' + quote('encode' + type.name) + '](value, bb);'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
lines.push(' var value = message[' + quote(field.name) + '];')
|
||||
lines.push(' if (value != null) {') // Comparing with null using "!=" also checks for undefined
|
||||
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' bb.writeVarUint(' + field.value + ');')
|
||||
}
|
||||
|
||||
if (field.isArray) {
|
||||
if (field.type === 'byte') {
|
||||
lines.push(' bb.writeByteArray(value);')
|
||||
} else {
|
||||
lines.push(' var values = value, n = values.length;')
|
||||
lines.push(' bb.writeVarUint(n);')
|
||||
lines.push(' for (var i = 0; i < n; i++) {')
|
||||
lines.push(' value = values[i];')
|
||||
lines.push(' ' + code)
|
||||
lines.push(' }')
|
||||
}
|
||||
} else {
|
||||
lines.push(' ' + code)
|
||||
}
|
||||
|
||||
if (definition.kind === 'STRUCT') {
|
||||
lines.push(' } else {')
|
||||
lines.push(
|
||||
' throw new Error(' + quote('Missing required field ' + quote(field.name)) + ');'
|
||||
)
|
||||
}
|
||||
|
||||
lines.push(' }')
|
||||
}
|
||||
|
||||
// A field id of zero is reserved to indicate the end of the message
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' bb.writeVarUint(0);')
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
lines.push(' if (isTopLevel) return bb.toUint8Array();')
|
||||
lines.push('}')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function compileSchemaJS(schema: Schema): string {
|
||||
let definitions: { [name: string]: Definition } = {}
|
||||
let name = schema.package
|
||||
let js: string[] = []
|
||||
|
||||
if (name !== null) {
|
||||
js.push('var ' + name + ' = exports || ' + name + ' || {}, exports;')
|
||||
} else {
|
||||
js.push('var exports = exports || {};')
|
||||
name = 'exports'
|
||||
}
|
||||
|
||||
js.push(name + '.ByteBuffer = ' + name + '.ByteBuffer || require("kiwi-schema").ByteBuffer;')
|
||||
|
||||
for (let i = 0; i < schema.definitions.length; i++) {
|
||||
let definition = schema.definitions[i]
|
||||
definitions[definition.name] = definition
|
||||
}
|
||||
|
||||
for (let i = 0; i < schema.definitions.length; i++) {
|
||||
let definition = schema.definitions[i]
|
||||
|
||||
switch (definition.kind) {
|
||||
case 'ENUM': {
|
||||
let value: any = {}
|
||||
for (let j = 0; j < definition.fields.length; j++) {
|
||||
let field = definition.fields[j]
|
||||
value[field.name] = field.value
|
||||
value[field.value] = field.name
|
||||
}
|
||||
js.push(name + '[' + quote(definition.name) + '] = ' + JSON.stringify(value, null, 2) + ';')
|
||||
break
|
||||
}
|
||||
|
||||
case 'STRUCT':
|
||||
case 'MESSAGE': {
|
||||
js.push('')
|
||||
js.push(
|
||||
name +
|
||||
'[' +
|
||||
quote('decode' + definition.name) +
|
||||
'] = ' +
|
||||
compileDecode(definition, definitions) +
|
||||
';'
|
||||
)
|
||||
js.push('')
|
||||
js.push(
|
||||
name +
|
||||
'[' +
|
||||
quote('encode' + definition.name) +
|
||||
'] = ' +
|
||||
compileEncode(definition, definitions) +
|
||||
';'
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
error(
|
||||
'Invalid definition kind ' + quote(definition.kind),
|
||||
definition.line,
|
||||
definition.column
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
js.push('')
|
||||
return js.join('\n')
|
||||
}
|
||||
|
||||
export function compileSchema(schema: Schema): any {
|
||||
let result = {
|
||||
ByteBuffer: ByteBuffer
|
||||
}
|
||||
new Function('exports', compileSchemaJS(schema))(result)
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
import { Schema, Definition, Field, DefinitionKind } from './schema'
|
||||
import { error, quote } from './util'
|
||||
|
||||
export let nativeTypes = ['bool', 'byte', 'float', 'int', 'int64', 'string', 'uint', 'uint64']
|
||||
|
||||
// These are special names on the object returned by compileSchema()
|
||||
export let reservedNames = ['ByteBuffer', 'package']
|
||||
|
||||
let regex = /((?:-|\b)\d+\b|[=;{}]|\[\]|\[deprecated\]|\b[A-Za-z_][A-Za-z0-9_]*\b|\/\/.*|\s+)/g
|
||||
let identifier = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
let whitespace = /^\/\/.*|\s+$/
|
||||
let equals = /^=$/
|
||||
let endOfFile = /^$/
|
||||
let semicolon = /^;$/
|
||||
let integer = /^-?\d+$/
|
||||
let leftBrace = /^\{$/
|
||||
let rightBrace = /^\}$/
|
||||
let arrayToken = /^\[\]$/
|
||||
let enumKeyword = /^enum$/
|
||||
let structKeyword = /^struct$/
|
||||
let messageKeyword = /^message$/
|
||||
let packageKeyword = /^package$/
|
||||
let deprecatedToken = /^\[deprecated\]$/
|
||||
|
||||
interface Token {
|
||||
text: string
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
function tokenize(text: string): Token[] {
|
||||
let parts = text.split(regex)
|
||||
let tokens = []
|
||||
let column = 0
|
||||
let line = 0
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
let part = parts[i]
|
||||
|
||||
// Keep non-whitespace tokens
|
||||
if (i & 1) {
|
||||
if (!whitespace.test(part)) {
|
||||
tokens.push({
|
||||
text: part,
|
||||
line: line + 1,
|
||||
column: column + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Detect syntax errors
|
||||
else if (part !== '') {
|
||||
error('Syntax error ' + quote(part), line + 1, column + 1)
|
||||
}
|
||||
|
||||
// Keep track of the line and column counts
|
||||
let lines = part.split('\n')
|
||||
if (lines.length > 1) column = 0
|
||||
line += lines.length - 1
|
||||
column += lines[lines.length - 1].length
|
||||
}
|
||||
|
||||
// End-of-file token
|
||||
tokens.push({
|
||||
text: '',
|
||||
line: line,
|
||||
column: column
|
||||
})
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function parse(tokens: Token[]): Schema {
|
||||
function current(): Token {
|
||||
return tokens[index]
|
||||
}
|
||||
|
||||
function eat(test: RegExp): boolean {
|
||||
if (test.test(current().text)) {
|
||||
index++
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function expect(test: RegExp, expected: string): void {
|
||||
if (!eat(test)) {
|
||||
let token = current()
|
||||
error('Expected ' + expected + ' but found ' + quote(token.text), token.line, token.column)
|
||||
}
|
||||
}
|
||||
|
||||
function unexpectedToken(): never {
|
||||
let token = current()
|
||||
error('Unexpected token ' + quote(token.text), token.line, token.column)
|
||||
}
|
||||
|
||||
let definitions: Definition[] = []
|
||||
let packageText = null
|
||||
let index = 0
|
||||
|
||||
if (eat(packageKeyword)) {
|
||||
packageText = current().text
|
||||
expect(identifier, 'identifier')
|
||||
expect(semicolon, '";"')
|
||||
}
|
||||
|
||||
while (index < tokens.length && !eat(endOfFile)) {
|
||||
let fields: Field[] = []
|
||||
let kind: DefinitionKind
|
||||
|
||||
if (eat(enumKeyword)) kind = 'ENUM'
|
||||
else if (eat(structKeyword)) kind = 'STRUCT'
|
||||
else if (eat(messageKeyword)) kind = 'MESSAGE'
|
||||
else unexpectedToken()
|
||||
|
||||
// All definitions start off the same
|
||||
let name = current()
|
||||
expect(identifier, 'identifier')
|
||||
expect(leftBrace, '"{"')
|
||||
|
||||
// Parse fields
|
||||
while (!eat(rightBrace)) {
|
||||
let type: string | null = null
|
||||
let isArray = false
|
||||
let isDeprecated = false
|
||||
|
||||
// Enums don't have types
|
||||
if (kind !== 'ENUM') {
|
||||
type = current().text
|
||||
expect(identifier, 'identifier')
|
||||
isArray = eat(arrayToken)
|
||||
}
|
||||
|
||||
let field = current()
|
||||
expect(identifier, 'identifier')
|
||||
|
||||
// Structs don't have explicit values
|
||||
let value: Token | null = null
|
||||
if (kind !== 'STRUCT') {
|
||||
expect(equals, '"="')
|
||||
value = current()
|
||||
expect(integer, 'integer')
|
||||
|
||||
if ((+value.text | 0) + '' !== value.text) {
|
||||
error('Invalid integer ' + quote(value.text), value.line, value.column)
|
||||
}
|
||||
}
|
||||
|
||||
let deprecated = current()
|
||||
if (eat(deprecatedToken)) {
|
||||
if (kind !== 'MESSAGE') {
|
||||
error('Cannot deprecate this field', deprecated.line, deprecated.column)
|
||||
}
|
||||
|
||||
isDeprecated = true
|
||||
}
|
||||
|
||||
expect(semicolon, '";"')
|
||||
|
||||
fields.push({
|
||||
name: field.text,
|
||||
line: field.line,
|
||||
column: field.column,
|
||||
type: type,
|
||||
isArray: isArray,
|
||||
isDeprecated: isDeprecated,
|
||||
value: value !== null ? +value.text | 0 : fields.length + 1
|
||||
})
|
||||
}
|
||||
|
||||
definitions.push({
|
||||
name: name.text,
|
||||
line: name.line,
|
||||
column: name.column,
|
||||
kind: kind,
|
||||
fields: fields
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
package: packageText,
|
||||
definitions: definitions
|
||||
}
|
||||
}
|
||||
|
||||
function verify(root: Schema): void {
|
||||
let definedTypes = nativeTypes.slice()
|
||||
let definitions: { [name: string]: Definition } = {}
|
||||
|
||||
// Define definitions
|
||||
for (let i = 0; i < root.definitions.length; i++) {
|
||||
let definition = root.definitions[i]
|
||||
if (definedTypes.includes(definition.name)) {
|
||||
error(
|
||||
'The type ' + quote(definition.name) + ' is defined twice',
|
||||
definition.line,
|
||||
definition.column
|
||||
)
|
||||
}
|
||||
if (reservedNames.includes(definition.name)) {
|
||||
error(
|
||||
'The type name ' + quote(definition.name) + ' is reserved',
|
||||
definition.line,
|
||||
definition.column
|
||||
)
|
||||
}
|
||||
definedTypes.push(definition.name)
|
||||
definitions[definition.name] = definition
|
||||
}
|
||||
|
||||
// Check fields
|
||||
for (let i = 0; i < root.definitions.length; i++) {
|
||||
let definition = root.definitions[i]
|
||||
let fields = definition.fields
|
||||
|
||||
if (definition.kind === 'ENUM' || fields.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check types
|
||||
for (let j = 0; j < fields.length; j++) {
|
||||
let field = fields[j]
|
||||
if (!definedTypes.includes(field.type!)) {
|
||||
error(
|
||||
'The type ' + quote(field.type!) + ' is not defined for field ' + quote(field.name),
|
||||
field.line,
|
||||
field.column
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check values
|
||||
let values: number[] = []
|
||||
for (let j = 0; j < fields.length; j++) {
|
||||
let field = fields[j]
|
||||
if (values.includes(field.value)) {
|
||||
error('The id for field ' + quote(field.name) + ' is used twice', field.line, field.column)
|
||||
}
|
||||
if (field.value <= 0) {
|
||||
error(
|
||||
'The id for field ' + quote(field.name) + ' must be positive',
|
||||
field.line,
|
||||
field.column
|
||||
)
|
||||
}
|
||||
// Figma schema uses sparse field IDs (up to 435), so skip sequential check
|
||||
values.push(field.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that structs don't contain themselves
|
||||
let state: { [name: string]: number } = {}
|
||||
let check = (name: string): boolean => {
|
||||
let definition = definitions[name]
|
||||
if (definition && definition.kind === 'STRUCT') {
|
||||
if (state[name] === 1) {
|
||||
error(
|
||||
'Recursive nesting of ' + quote(name) + ' is not allowed',
|
||||
definition.line,
|
||||
definition.column
|
||||
)
|
||||
}
|
||||
if (state[name] !== 2 && definition) {
|
||||
state[name] = 1
|
||||
let fields = definition.fields
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
let field = fields[i]
|
||||
if (!field.isArray) {
|
||||
check(field.type!)
|
||||
}
|
||||
}
|
||||
state[name] = 2
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for (let i = 0; i < root.definitions.length; i++) {
|
||||
check(root.definitions[i].name)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSchema(text: string): Schema {
|
||||
let schema = parse(tokenize(text))
|
||||
verify(schema)
|
||||
return schema
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
export interface Schema {
|
||||
package: string | null
|
||||
definitions: Definition[]
|
||||
}
|
||||
|
||||
export type DefinitionKind = 'ENUM' | 'STRUCT' | 'MESSAGE'
|
||||
|
||||
export interface Definition {
|
||||
name: string
|
||||
line: number
|
||||
column: number
|
||||
kind: DefinitionKind
|
||||
fields: Field[]
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
name: string
|
||||
line: number
|
||||
column: number
|
||||
type: string | null
|
||||
isArray: boolean
|
||||
isDeprecated: boolean
|
||||
value: number
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
export function quote(text: string): string {
|
||||
return JSON.stringify(text)
|
||||
}
|
||||
|
||||
export function error(text: string, line: number, column: number): never {
|
||||
var error = new Error(text)
|
||||
;(error as any).line = line
|
||||
;(error as any).column = column
|
||||
throw error
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import type { Definition, Field, Schema } from './schema'
|
||||
import { error, quote } from './util'
|
||||
|
||||
export function findDefinition(schema: Schema, name: string): Definition | null {
|
||||
return schema.definitions.find((definition) => definition.name === name) ?? null
|
||||
}
|
||||
|
||||
export function findField(schema: Schema, definitionName: string, fieldName: string): Field | null {
|
||||
return (
|
||||
findDefinition(schema, definitionName)?.fields.find((field) => field.name === fieldName) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function expectFieldNumber(
|
||||
schema: Schema,
|
||||
definitionName: string,
|
||||
fieldName: string,
|
||||
expectedValue: number
|
||||
): void {
|
||||
const field = findField(schema, definitionName, fieldName)
|
||||
if (!field) {
|
||||
throw new Error(`Missing field ${definitionName}.${fieldName}`)
|
||||
}
|
||||
if (field.value !== expectedValue) {
|
||||
throw new Error(
|
||||
`Expected ${definitionName}.${fieldName} to use field ${expectedValue}, got ${field.value}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function expectEnumValue(
|
||||
schema: Schema,
|
||||
enumName: string,
|
||||
memberName: string,
|
||||
expectedValue: number
|
||||
): void {
|
||||
const definition = findDefinition(schema, enumName)
|
||||
if (!definition) {
|
||||
throw new Error(`Missing enum ${enumName}`)
|
||||
}
|
||||
if (definition.kind !== 'ENUM') {
|
||||
throw new Error(`${enumName} is a ${definition.kind}, not an enum`)
|
||||
}
|
||||
const field = definition.fields.find((candidate) => candidate.name === memberName)
|
||||
if (!field) {
|
||||
throw new Error(`Missing enum member ${enumName}.${memberName}`)
|
||||
}
|
||||
if (field.value !== expectedValue) {
|
||||
throw new Error(
|
||||
`Expected ${enumName}.${memberName} to use value ${expectedValue}, got ${field.value}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSchema(schema: Schema): void {
|
||||
for (const definition of schema.definitions) {
|
||||
validateUniqueFieldNames(definition)
|
||||
if (definition.kind === 'ENUM') validateUniqueEnumValues(definition)
|
||||
}
|
||||
}
|
||||
|
||||
function validateUniqueFieldNames(definition: Definition): void {
|
||||
const fieldsByName = new Set<string>()
|
||||
for (const field of definition.fields) {
|
||||
if (fieldsByName.has(field.name)) {
|
||||
error(
|
||||
`The field ${quote(field.name)} is defined twice in ${quote(definition.name)}`,
|
||||
field.line,
|
||||
field.column
|
||||
)
|
||||
}
|
||||
fieldsByName.add(field.name)
|
||||
}
|
||||
}
|
||||
|
||||
function validateUniqueEnumValues(definition: Definition): void {
|
||||
const fieldsByValue = new Set<number>()
|
||||
for (const field of definition.fields) {
|
||||
if (fieldsByValue.has(field.value)) {
|
||||
error(
|
||||
`The enum value ${field.value} is used twice in ${quote(definition.name)}`,
|
||||
field.line,
|
||||
field.column
|
||||
)
|
||||
}
|
||||
fieldsByValue.add(field.value)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
"declaration": true,
|
||||
"declarationMap": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"types": ["bun"]
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ describe('text node export', () => {
|
|||
|
||||
const { unzipSync, inflateSync } = await import('fflate')
|
||||
const { decodeBinarySchema, compileSchema, ByteBuffer } =
|
||||
await import('#core/kiwi/schema-runtime')
|
||||
await import('@open-pencil/kiwi/schema-runtime')
|
||||
const { parseFigKiwiChunks } = await import('@open-pencil/core')
|
||||
|
||||
const graph = new SceneGraph()
|
||||
|
|
@ -132,7 +132,7 @@ describe('text node export', () => {
|
|||
|
||||
const { unzipSync, inflateSync } = await import('fflate')
|
||||
const { decodeBinarySchema, compileSchema, ByteBuffer } =
|
||||
await import('#core/kiwi/schema-runtime')
|
||||
await import('@open-pencil/kiwi/schema-runtime')
|
||||
const { parseFigKiwiChunks } = await import('@open-pencil/core')
|
||||
|
||||
const graph = new SceneGraph()
|
||||
|
|
@ -178,7 +178,7 @@ describe('text node export', () => {
|
|||
|
||||
const { unzipSync, inflateSync } = await import('fflate')
|
||||
const { decodeBinarySchema, compileSchema, ByteBuffer } =
|
||||
await import('#core/kiwi/schema-runtime')
|
||||
await import('@open-pencil/kiwi/schema-runtime')
|
||||
const { parseFigKiwiChunks } = await import('@open-pencil/core')
|
||||
|
||||
const graph = new SceneGraph()
|
||||
|
|
@ -227,7 +227,7 @@ describe('text node export', () => {
|
|||
|
||||
const { unzipSync, inflateSync } = await import('fflate')
|
||||
const { decodeBinarySchema, compileSchema, ByteBuffer } =
|
||||
await import('#core/kiwi/schema-runtime')
|
||||
await import('@open-pencil/kiwi/schema-runtime')
|
||||
const { parseFigKiwiChunks } = await import('@open-pencil/core')
|
||||
|
||||
const graph = new SceneGraph()
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { readFileSync } from 'node:fs'
|
|||
|
||||
import ts from 'typescript'
|
||||
|
||||
import { parseSchema } from '@open-pencil/kiwi/schema-runtime'
|
||||
|
||||
import { FIGMA_RAW_NODE_FIELD_KEYS } from '#core/kiwi/fig/node-change/convert'
|
||||
import { parseSchema } from '#core/kiwi/schema-runtime'
|
||||
|
||||
interface SchemaField {
|
||||
name: string
|
||||
|
|
@ -28,7 +29,7 @@ type SchemaCoverageBucket =
|
|||
| 'mediaMotionMetadata'
|
||||
| 'internalBookkeeping'
|
||||
|
||||
const SCHEMA_PATH = 'packages/core/src/kiwi/fig/codec/schema/fig.kiwi'
|
||||
const SCHEMA_PATH = 'packages/kiwi/src/fig/schema/fig.kiwi'
|
||||
const CODEC_PATH = 'packages/core/src/kiwi/fig/codec/index.ts'
|
||||
|
||||
function nodeChangeSchemaFields(): SchemaField[] {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import figSchema from '#core/kiwi/fig/codec/schema'
|
||||
import { expectEnumValue, expectFieldNumber, validateSchema } from '#core/kiwi/schema-runtime'
|
||||
import { figmaSchema as figSchema } from '@open-pencil/kiwi/fig'
|
||||
import {
|
||||
expectEnumValue,
|
||||
expectFieldNumber,
|
||||
validateSchema
|
||||
} from '@open-pencil/kiwi/schema-runtime'
|
||||
|
||||
describe('Kiwi schema runtime', () => {
|
||||
test('validates the static Figma schema', () => {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,12 @@ import { SceneGraph } from '#core/scene-graph'
|
|||
import { fontManager } from '#core/text'
|
||||
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
import { coreSourcePath, publicPath, testPath as repoTestPath } from '#tests/helpers/paths'
|
||||
import {
|
||||
coreSourcePath,
|
||||
publicPath,
|
||||
repoPath,
|
||||
testPath as repoTestPath
|
||||
} from '#tests/helpers/paths'
|
||||
|
||||
// === CLAIM EXTRACTION ===
|
||||
// Each claim is: [doc_section, claim_text, verification_strategy]
|
||||
|
|
@ -34,7 +39,7 @@ const rendererPath = coreSourcePath('canvas/renderer.ts')
|
|||
const sgTypesPath = coreSourcePath('scene-graph/types.ts')
|
||||
const nodeExportPath = coreSourcePath('kiwi/fig/node-change/export-node.ts')
|
||||
const convertPath = coreSourcePath('kiwi/fig/node-change/paint.ts')
|
||||
const schemaPath = coreSourcePath('kiwi/fig/codec/schema/fig.kiwi')
|
||||
const schemaPath = repoPath('packages/kiwi/src/fig/schema/fig.kiwi')
|
||||
const codecPath = coreSourcePath('kiwi/fig/codec/index.ts')
|
||||
const lifecyclePath = coreSourcePath('canvas/renderer/lifecycle.ts')
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue