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:
parent
4bf161b983
commit
866cabd623
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
62
tests/engine/fig/heavy/component-metadata.test.ts
Normal file
62
tests/engine/fig/heavy/component-metadata.test.ts
Normal 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)
|
||||
})
|
||||
Loading…
Reference in a new issue