Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/figma

src/models.ts

262 lines · 8.5 KB · TypeScript
import type {
	FigmaComment,
	FigmaComponent,
	FigmaFileMeta,
	FigmaFileSummary,
	FigmaImageFill,
	FigmaImageMap,
	FigmaNodeBundle,
	FigmaNodeSummary,
	FigmaStyle,
	FigmaUserRef,
	JsonRecord,
} from './types.ts'

function asRecord(value: unknown): JsonRecord {
	return value && typeof value === 'object' && !Array.isArray(value)
		? (value as JsonRecord)
		: {}
}

export function mapUser(value: unknown): FigmaUserRef | null {
	const record = asRecord(value)
	const id = typeof record.id === 'string' ? record.id : ''
	if (!id) return null
	return {
		id,
		handle: typeof record.handle === 'string' ? record.handle : null,
	}
}

export function mapNode(value: unknown): FigmaNodeSummary | null {
	const record = asRecord(value)
	const id = typeof record.id === 'string' ? record.id : ''
	if (!id) return null
	const children = Array.isArray(record.children)
		? record.children
				.map(mapNode)
				.filter((node): node is FigmaNodeSummary => node !== null)
		: []
	return {
		id,
		name: typeof record.name === 'string' ? record.name : '',
		type: typeof record.type === 'string' ? record.type : '',
		visible: record.visible !== false,
		children,
	}
}

export function mapFile(fileKey: string, value: unknown): FigmaFileSummary {
	const record = asRecord(value)
	const components =
		record.components && typeof record.components === 'object'
			? Object.keys(record.components as JsonRecord).length
			: 0
	const styles =
		record.styles && typeof record.styles === 'object'
			? Object.keys(record.styles as JsonRecord).length
			: 0
	return {
		fileKey,
		name: typeof record.name === 'string' ? record.name : '',
		lastModified: typeof record.lastModified === 'string' ? record.lastModified : null,
		version: typeof record.version === 'string' ? record.version : null,
		role: typeof record.role === 'string' ? record.role : null,
		editorType: typeof record.editorType === 'string' ? record.editorType : null,
		thumbnailUrl: typeof record.thumbnailUrl === 'string' ? record.thumbnailUrl : null,
		document: mapNode(record.document),
		componentCount: components,
		styleCount: styles,
	}
}

export function mapFileMeta(fileKey: string, value: unknown): FigmaFileMeta {
	const record = asRecord(value)
	const file = asRecord(record.file)
	const source = Object.keys(file).length > 0 ? file : record
	return {
		fileKey,
		name: typeof source.name === 'string' ? source.name : '',
		lastModified:
			typeof source.last_touched_at === 'string'
				? source.last_touched_at
				: typeof source.lastModified === 'string'
					? source.lastModified
					: null,
		thumbnailUrl: typeof source.thumbnail_url === 'string' ? source.thumbnail_url : null,
		creator: mapUser(source.creator),
		lastTouchedBy: mapUser(source.last_touched_by),
		folderName: typeof source.folder_name === 'string' ? source.folder_name : null,
		editorType: typeof source.editor_type === 'string' ? source.editor_type : null,
		role: typeof source.role === 'string' ? source.role : null,
		version: typeof source.version === 'string' ? source.version : null,
		linkAccess: typeof source.link_access === 'string' ? source.link_access : null,
		url: typeof source.url === 'string' ? source.url : null,
	}
}

export function mapNodeBundle(id: string, value: unknown): FigmaNodeBundle {
	const record = asRecord(value)
	const components =
		record.components && typeof record.components === 'object'
			? Object.keys(record.components as JsonRecord).length
			: 0
	const styles =
		record.styles && typeof record.styles === 'object'
			? Object.keys(record.styles as JsonRecord).length
			: 0
	return {
		id,
		document: mapNode(record.document),
		componentCount: components,
		styleCount: styles,
	}
}

function mapReactions(value: unknown): Array<{ emoji: string; count: number }> {
	if (!Array.isArray(value)) return []
	const counts = new Map<string, number>()
	for (const item of value) {
		const record = asRecord(item)
		const emoji = typeof record.emoji === 'string' ? record.emoji : ''
		if (!emoji) continue
		counts.set(emoji, (counts.get(emoji) ?? 0) + 1)
	}
	return [...counts.entries()].map(([emoji, count]) => ({ emoji, count }))
}

export function mapComment(fileKey: string, value: unknown): FigmaComment {
	const record = asRecord(value)
	return {
		id: typeof record.id === 'string' ? record.id : '',
		message: typeof record.message === 'string' ? record.message : null,
		fileKey,
		parentId: typeof record.parent_id === 'string' ? record.parent_id : null,
		createdAt: typeof record.created_at === 'string' ? record.created_at : null,
		resolvedAt: typeof record.resolved_at === 'string' ? record.resolved_at : null,
		orderId: typeof record.order_id === 'string' ? record.order_id : null,
		user: mapUser(record.user),
		clientMeta:
			record.client_meta && typeof record.client_meta === 'object'
				? (record.client_meta as JsonRecord)
				: null,
		reactions: mapReactions(record.reactions),
	}
}

function mapLibraryItem(value: unknown): {
	key: string
	fileKey: string | null
	nodeId: string | null
	name: string
	description: string | null
	thumbnailUrl: string | null
	createdAt: string | null
	updatedAt: string | null
	containingFrameName: string | null
	styleType: string | null
} {
	const record = asRecord(value)
	const frame = asRecord(record.containing_frame)
	return {
		key: typeof record.key === 'string' ? record.key : '',
		fileKey: typeof record.file_key === 'string' ? record.file_key : null,
		nodeId: typeof record.node_id === 'string' ? record.node_id : null,
		name: typeof record.name === 'string' ? record.name : '',
		description: typeof record.description === 'string' ? record.description : null,
		thumbnailUrl: typeof record.thumbnail_url === 'string' ? record.thumbnail_url : null,
		createdAt: typeof record.created_at === 'string' ? record.created_at : null,
		updatedAt: typeof record.updated_at === 'string' ? record.updated_at : null,
		containingFrameName:
			typeof frame.name === 'string'
				? frame.name
				: typeof record.containing_frame === 'string'
					? record.containing_frame
					: null,
		styleType: typeof record.style_type === 'string' ? record.style_type : null,
	}
}

export function mapComponent(value: unknown): FigmaComponent {
	const item = mapLibraryItem(value)
	return {
		key: item.key,
		fileKey: item.fileKey,
		nodeId: item.nodeId,
		name: item.name,
		description: item.description,
		thumbnailUrl: item.thumbnailUrl,
		containingFrameName: item.containingFrameName,
		createdAt: item.createdAt,
		updatedAt: item.updatedAt,
	}
}

export function mapStyle(value: unknown): FigmaStyle {
	const item = mapLibraryItem(value)
	return {
		key: item.key,
		fileKey: item.fileKey,
		nodeId: item.nodeId,
		name: item.name,
		description: item.description,
		styleType: item.styleType,
		thumbnailUrl: item.thumbnailUrl,
		createdAt: item.createdAt,
		updatedAt: item.updatedAt,
	}
}

function valuesFromMetaMap(value: unknown, key: 'components' | 'styles'): Array<unknown> {
	const root = asRecord(value)
	const meta = asRecord(root.meta)
	const collection = meta[key] ?? root[key]
	if (Array.isArray(collection)) return collection
	if (collection && typeof collection === 'object') return Object.values(collection)
	return []
}

export function mapComponentList(value: unknown): Array<FigmaComponent> {
	return valuesFromMetaMap(value, 'components')
		.map(mapComponent)
		.filter((item) => item.key.length > 0 || (item.nodeId ?? '').length > 0)
}

export function mapStyleList(value: unknown): Array<FigmaStyle> {
	return valuesFromMetaMap(value, 'styles')
		.map(mapStyle)
		.filter((item) => item.key.length > 0 || (item.name ?? '').length > 0)
}

export function mapImageMap(fileKey: string, value: unknown): FigmaImageMap {
	const record = asRecord(value)
	const images =
		record.images && typeof record.images === 'object' ? (record.images as JsonRecord) : {}
	return {
		fileKey,
		images: Object.entries(images).map(([nodeId, url]) => ({
			nodeId,
			url: typeof url === 'string' ? url : null,
		})),
		error: typeof record.err === 'string' ? record.err : null,
	}
}

export function mapImageFills(value: unknown): Array<FigmaImageFill> {
	const record = asRecord(value)
	const meta = asRecord(record.meta)
	const images =
		meta.images && typeof meta.images === 'object'
			? (meta.images as JsonRecord)
			: record.images && typeof record.images === 'object'
				? (record.images as JsonRecord)
				: {}
	return Object.entries(images)
		.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
		.map(([hash, url]) => ({ hash, url }))
}

export function asArray<T>(value: T | Array<T> | null | undefined): Array<T> {
	if (value === undefined || value === null) return []
	return Array.isArray(value) ? value : [value]
}