From 866cabd623aab0842f1064279774a13f1c447a75 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 12 May 2026 21:48:42 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + packages/core/src/kiwi/fig/import.ts | 14 +++ packages/core/src/kiwi/node-change/convert.ts | 100 +++++++++++++++++- .../core/src/kiwi/node-change/export-node.ts | 47 ++++++++ .../core/src/scene-graph/node-defaults.ts | 11 ++ packages/core/src/scene-graph/types.ts | 22 ++++ .../fig/heavy/component-metadata.test.ts | 62 +++++++++++ 7 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 tests/engine/fig/heavy/component-metadata.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4e506a2..01e119f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/core/src/kiwi/fig/import.ts b/packages/core/src/kiwi/fig/import.ts index 358bc30e6..31229bbb0 100644 --- a/packages/core/src/kiwi/fig/import.ts +++ b/packages/core/src/kiwi/fig/import.ts @@ -330,6 +330,19 @@ function remapComponentIds(graph: SceneGraph, guidToNodeId: Map) } } +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 = {} + 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() diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index 66ee36155..fcc79df41 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -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 = { 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 { + 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 = {} + 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 = {} @@ -479,6 +514,63 @@ function extractComponentPropertyValues(nc: NodeChange): Record 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 + 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> | 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 diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index e0954f8c5..f07bb8a85 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -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 @@ -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 => 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 => 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) diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index 2228a783d..96feef92d 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -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: [], diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index b58ddbf37..28c637944 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -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 componentPropertyDefinitions: ComponentPropertyDefinition[] componentPropertyValues: Record + 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 diff --git a/tests/engine/fig/heavy/component-metadata.test.ts b/tests/engine/fig/heavy/component-metadata.test.ts new file mode 100644 index 000000000..99ce72db9 --- /dev/null +++ b/tests/engine/fig/heavy/component-metadata.test.ts @@ -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) +})