feat(kiwi): preserve component library metadata

- Import Figma component keys, library keys, publish/version IDs, descriptions, links, and variant specs
- Export verified component metadata back into NodeChange fields
- Use variantPropSpecs to derive component property values after component set imports
- Cover large fixture metadata import as heavy tests
This commit is contained in:
Danila Poyarkov 2026-05-12 21:48:42 +03:00
parent 4bf161b983
commit 866cabd623
7 changed files with 253 additions and 4 deletions

View file

@ -10,6 +10,7 @@
- Add editor event bus with typed lifecycle events — subscribe via `editor.onEditorEvent()` in core or `useEditorEvent()` composable in the Vue SDK.
- Register desktop file associations for `.fig` and `.pen` so supported design files can be opened from OS file browsers with OpenPencil.
- Add a local Assets panel for document components and component sets, with grouped variant assets, default variant insertion, and duplicate variant warnings.
- Preserve Figma component library metadata on import/export, including component keys, source library keys, publish/version IDs, descriptions, links, and variant property specs.
### Changed

View file

@ -330,6 +330,19 @@ function remapComponentIds(graph: SceneGraph, guidToNodeId: Map<string, string>)
}
}
function applyVariantPropSpecs(graph: SceneGraph): void {
for (const node of graph.getAllNodes()) {
if (node.type !== 'COMPONENT' || node.variantPropSpecs.length === 0 || !node.parentId) continue
const parent = graph.getNode(node.parentId)
if (parent?.type !== 'COMPONENT_SET') continue
const defs = new Map(parent.componentPropertyDefinitions.map((def) => [def.id, def.name]))
const values: Record<string, string> = {}
for (const spec of node.variantPropSpecs)
values[defs.get(spec.propDefId) ?? spec.propDefId] = spec.value
graph.updateNode(node.id, { componentPropertyValues: values })
}
}
function parseDocumentColorSpace(nodeChanges: NodeChange[]): 'srgb' | 'display-p3' {
const documentNode = nodeChanges.find((nc) => nc.type === 'DOCUMENT')
return documentNode?.documentColorProfile === 'DISPLAY_P3' ? 'display-p3' : 'srgb'
@ -392,6 +405,7 @@ export function importNodeChanges(
importVariableEntries(changeMap, parentMap, graph, assetRefs)
importVariableBindings(changeMap, guidToNodeId, graph)
remapComponentIds(graph, guidToNodeId)
applyVariantPropSpecs(graph)
const firstPageId = graph.getPages()[0]?.id
const componentPageIds = new Set<string>()

View file

@ -39,7 +39,9 @@ import type {
ArcData,
VectorNetwork,
ComponentPropertyDefinition,
ComponentPropertyType
ComponentPropertyType,
SymbolLink,
VariantPropSpec
} from '#core/scene-graph'
import type { GUID } from '#core/types'
@ -436,20 +438,37 @@ export function nodeChangeToProps(
clipsContent: nc.frameMaskDisabled === false && nc.resizeToFit !== true,
componentId: extractSymbolId(nc),
componentPropertyDefinitions: extractComponentPropertyDefs(nc),
componentPropertyValues: extractComponentPropertyValues(nc)
componentPropertyValues: extractComponentPropertyValues(nc),
...extractComponentMetadata(nc)
}
}
const COMPONENT_PROP_TYPE_MAP: Record<string, ComponentPropertyType> = {
VARIANT: 'VARIANT',
TEXT: 'TEXT',
BOOL: 'BOOLEAN',
BOOLEAN: 'BOOLEAN',
INSTANCE_SWAP: 'INSTANCE_SWAP'
}
function componentPropValueToString(value: unknown): string {
if (!value || typeof value !== 'object') return ''
const propValue = value as {
boolValue?: boolean
textValue?: string | { characters?: string }
guidValue?: GUID
}
if (typeof propValue.boolValue === 'boolean') return String(propValue.boolValue)
if (typeof propValue.textValue === 'string') return propValue.textValue
if (propValue.textValue && typeof propValue.textValue === 'object') {
return propValue.textValue.characters ?? ''
}
return propValue.guidValue ? guidToString(propValue.guidValue) : ''
}
function extractComponentPropertyDefs(nc: NodeChange): ComponentPropertyDefinition[] {
const defs = nc.componentPropDefs as
| Array<{ id?: GUID; name?: string; type?: string; initialValue?: { textValue?: string } }>
| Array<{ id?: GUID; name?: string; type?: string; initialValue?: unknown }>
| undefined
if (!defs?.length) return []
const result: ComponentPropertyDefinition[] = []
@ -460,14 +479,30 @@ function extractComponentPropertyDefs(nc: NodeChange): ComponentPropertyDefiniti
id: guidToString(def.id),
name: def.name,
type: propType,
defaultValue: def.initialValue?.textValue ?? '',
defaultValue: componentPropValueToString(def.initialValue),
variantOptions: propType === 'VARIANT' ? undefined : undefined
})
}
return result
}
function extractVariantPropSpecs(nc: NodeChange): VariantPropSpec[] {
const specs = nc.variantPropSpecs as Array<{ propDefId?: GUID; value?: string }> | undefined
if (!specs?.length) return []
return specs
.filter((spec): spec is { propDefId: GUID; value?: string } => !!spec.propDefId)
.map((spec) => ({ propDefId: guidToString(spec.propDefId), value: spec.value ?? '' }))
}
function extractComponentPropertyValues(nc: NodeChange): Record<string, string> {
const specs = extractVariantPropSpecs(nc)
const defs = new Map(extractComponentPropertyDefs(nc).map((def) => [def.id, def.name]))
if (specs.length > 0 && defs.size > 0) {
const values: Record<string, string> = {}
for (const spec of specs) values[defs.get(spec.propDefId) ?? spec.propDefId] = spec.value
return values
}
const name = nc.name
if (!name?.includes('=')) return {}
const values: Record<string, string> = {}
@ -479,6 +514,63 @@ function extractComponentPropertyValues(nc: NodeChange): Record<string, string>
return values
}
type ComponentMetadataProps = Pick<
SceneNode,
| 'componentKey'
| 'sourceLibraryKey'
| 'publishId'
| 'overrideKey'
| 'sharedSymbolVersion'
| 'publishedVersion'
| 'isPublishable'
| 'isSymbolPublishable'
| 'symbolDescription'
| 'symbolLinks'
| 'variantPropSpecs'
>
function guidToStringOrNull(value: unknown): string | null {
if (!value || typeof value !== 'object') return null
const guid = value as Partial<GUID>
if (typeof guid.sessionID !== 'number' || typeof guid.localID !== 'number') return null
return guidToString({ sessionID: guid.sessionID, localID: guid.localID })
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
function stringOrEmpty(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function booleanOrFalse(value: unknown): boolean {
return typeof value === 'boolean' ? value : false
}
function extractComponentMetadata(nc: NodeChange): ComponentMetadataProps {
const symbolLinks = (nc.symbolLinks as Array<Partial<SymbolLink>> | undefined) ?? []
return {
componentKey: stringOrNull(nc.componentKey),
sourceLibraryKey: stringOrNull(nc.sourceLibraryKey),
publishId: guidToStringOrNull(nc.publishID),
overrideKey: guidToStringOrNull(nc.overrideKey),
sharedSymbolVersion: stringOrNull(nc.sharedSymbolVersion),
publishedVersion: stringOrNull(nc.publishedVersion),
isPublishable: booleanOrFalse(nc.isPublishable),
isSymbolPublishable: booleanOrFalse(nc.isSymbolPublishable),
symbolDescription: stringOrEmpty(nc.symbolDescription),
symbolLinks: symbolLinks
.filter((link): link is SymbolLink => typeof link.uri === 'string')
.map((link) => ({
uri: link.uri,
displayName: link.displayName,
displayText: link.displayText
})),
variantPropSpecs: extractVariantPropSpecs(nc)
}
}
function isComponentSet(nc: NodeChange): boolean {
const defs = nc.componentPropDefs as Array<{ type?: string }> | undefined
if (!defs?.length) return false

View file

@ -2,6 +2,7 @@ import type { NodeChange, Paint } from '#core/kiwi/binary/codec'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import type { Color, GUID, Matrix } from '#core/types'
import { stringToGuid } from './guid'
import { mergePluginData, serializePluginRelaunchData } from './plugin-data'
export type KiwiNodeChange = NodeChange & Record<string, unknown>
@ -53,6 +54,51 @@ function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): P
}))
}
function componentPropertyValue(value: string) {
return { textValue: { characters: value } }
}
function parseGuidOrNull(value: string) {
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
}
function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
if (node.componentKey) nc.componentKey = node.componentKey
if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey
const publishId = node.publishId ? parseGuidOrNull(node.publishId) : null
const overrideKey = node.overrideKey ? parseGuidOrNull(node.overrideKey) : null
if (publishId) nc.publishID = publishId
if (overrideKey) nc.overrideKey = overrideKey
if (node.sharedSymbolVersion) nc.sharedSymbolVersion = node.sharedSymbolVersion
if (node.publishedVersion) nc.publishedVersion = node.publishedVersion
if (node.isPublishable) nc.isPublishable = true
if (node.isSymbolPublishable) nc.isSymbolPublishable = true
if (node.symbolDescription) nc.symbolDescription = node.symbolDescription
if (node.symbolLinks.length > 0) nc.symbolLinks = structuredClone(node.symbolLinks)
const componentPropDefs = node.componentPropertyDefinitions
.map((def) => {
const id = parseGuidOrNull(def.id)
return id
? {
id,
name: def.name,
type: def.type,
initialValue: componentPropertyValue(def.defaultValue)
}
: null
})
.filter((def): def is NonNullable<typeof def> => def !== null)
if (componentPropDefs.length > 0) nc.componentPropDefs = componentPropDefs
const variantPropSpecs = node.variantPropSpecs
.map((spec) => {
const propDefId = parseGuidOrNull(spec.propDefId)
return propDefId ? { propDefId, value: spec.value } : null
})
.filter((spec): spec is NonNullable<typeof spec> => spec !== null)
if (variantPropSpecs.length > 0) nc.variantPropSpecs = variantPropSpecs
}
function applyNodeVisualProps(
context: SceneNodeToKiwiContext,
node: SceneNode,
@ -130,6 +176,7 @@ export function sceneNodeToKiwiWithContext(
}
applyNodeVisualProps(context, node, nc)
applyComponentMetadata(node, nc)
if (strokePaints.length > 0) nc.strokePaints = strokePaints
context.serializeLayoutProps(node, nc)

View file

@ -106,6 +106,17 @@ export function createDefaultNode(
overrides: {},
componentPropertyDefinitions: [],
componentPropertyValues: {},
componentKey: null,
sourceLibraryKey: null,
publishId: null,
overrideKey: null,
sharedSymbolVersion: null,
publishedVersion: null,
isPublishable: false,
isSymbolPublishable: false,
symbolDescription: '',
symbolLinks: [],
variantPropSpecs: [],
boundVariables: {},
pluginData: [],
pluginRelaunchData: [],

View file

@ -209,6 +209,17 @@ export interface PluginRelaunchDataEntry {
isDeleted: boolean
}
export interface SymbolLink {
uri: string
displayName?: string
displayText?: string
}
export interface VariantPropSpec {
propDefId: string
value: string
}
export interface SceneNode {
id: string
type: NodeType
@ -327,6 +338,17 @@ export interface SceneNode {
overrides: Record<string, unknown>
componentPropertyDefinitions: ComponentPropertyDefinition[]
componentPropertyValues: Record<string, string>
componentKey: string | null
sourceLibraryKey: string | null
publishId: string | null
overrideKey: string | null
sharedSymbolVersion: string | null
publishedVersion: string | null
isPublishable: boolean
isSymbolPublishable: boolean
symbolDescription: string
symbolLinks: SymbolLink[]
variantPropSpecs: VariantPropSpec[]
boundVariables: Record<string, string>

View file

@ -0,0 +1,62 @@
import { expect, setDefaultTimeout, test } from 'bun:test'
import { readFileSync } from 'fs'
import { importNodeChanges } from '#core/kiwi'
import { parseFigBuffer } from '#core/kiwi/fig/parse/core'
import { expectDefined } from '#tests/helpers/assert'
import { heavy } from '#tests/helpers/test-utils'
function importFixture(name: string) {
const buffer = readFileSync(`tests/fixtures/${name}`).buffer
const { nodeChanges, blobs, images } = parseFigBuffer(buffer)
return importNodeChanges(nodeChanges, blobs, new Map(images))
}
setDefaultTimeout(20_000)
heavy('fig component metadata import', () => {
test('preserves remote library component identity fields', () => {
const graph = importFixture('gold-preview.fig')
const component = expectDefined(
graph
.getAllNodes()
.find((node) => node.componentKey === '26164e029c485511adfa634522024c7c23e7bb81'),
'remote component'
)
expect(component.sourceLibraryKey).toStartWith('lk-')
expect(component.publishId).toBe('4132:5801')
expect(component.overrideKey).toBe('4132:5801')
expect(component.sharedSymbolVersion).toBe('4152:1913')
expect(component.isSymbolPublishable).toBe(false)
}, 10_000)
test('imports component set docs and variant property specs', () => {
const graph = importFixture('material3.fig')
const buttonSet = expectDefined(
graph.getAllNodes().find((node) => node.type === 'COMPONENT_SET' && node.name === 'Button'),
'Button component set'
)
expect(buttonSet.isPublishable).toBe(true)
expect(buttonSet.symbolDescription).toContain('Buttons communicate actions')
expect(buttonSet.symbolLinks.map((link) => link.uri)).toContain(
'http://m3.material.io/components/buttons/overview'
)
expect(buttonSet.componentPropertyDefinitions.map((def) => def.name)).toContain('State')
const variant = expectDefined(
graph
.getChildren(buttonSet.id)
.find((node) => node.type === 'COMPONENT' && node.name.includes('State=Disabled')),
'disabled Button variant'
)
expect(variant.variantPropSpecs.length).toBeGreaterThan(0)
expect(variant.componentPropertyValues.State).toBe('Disabled')
expect(variant.componentPropertyValues.Style).toBe('Tonal')
expect(Object.keys(variant.componentPropertyValues).some((key) => key.includes(':'))).toBe(
false
)
}, 10_000)
})