@primitiv-ui/tokens 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dtcg.ts ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Pure transform from Figma-shaped variable data to DTCG-shaped tokens.
3
+ *
4
+ * Inputs mirror what the sync plugin's sandbox extracts from the Figma
5
+ * Plugin Variables API; outputs follow the Design Token Community Group
6
+ * spec. This module is import-only — it does not read the filesystem or
7
+ * the network. The HTTP server that persists the output lives alongside
8
+ * it but is a separate module.
9
+ */
10
+
11
+ export type FigmaCollection = {
12
+ id: string
13
+ name: string
14
+ modes: { modeId: string; name: string }[]
15
+ defaultModeId: string
16
+ }
17
+
18
+ export type FigmaResolvedType = 'BOOLEAN' | 'COLOR' | 'FLOAT' | 'STRING'
19
+
20
+ export type FigmaRgba = { r: number; g: number; b: number; a: number }
21
+
22
+ export type FigmaAlias = { type: 'VARIABLE_ALIAS'; id: string }
23
+
24
+ export type FigmaVariable = {
25
+ id: string
26
+ name: string
27
+ resolvedType: FigmaResolvedType
28
+ variableCollectionId: string
29
+ valuesByMode: Record<string, unknown>
30
+ }
31
+
32
+ export type DtcgType = 'string' | 'number' | 'color' | 'boolean'
33
+
34
+ export type DtcgValue = string | number | boolean
35
+
36
+ export type DtcgToken = {
37
+ $type: DtcgType
38
+ $value: DtcgValue
39
+ }
40
+
41
+ export type DtcgGroup = { [key: string]: DtcgGroup | DtcgToken }
42
+
43
+ /** One DTCG file per Figma collection. */
44
+ export type DtcgFiles = {
45
+ primitives: DtcgGroup
46
+ palette: DtcgGroup
47
+ foreground: DtcgGroup
48
+ intent: DtcgGroup
49
+ context: DtcgGroup
50
+ interaction: DtcgGroup
51
+ }
52
+
53
+ /** Where a single-mode Figma collection lands in the DTCG output. */
54
+ type Routing = { file: keyof DtcgFiles; prefix: string[] }
55
+
56
+ /** Resolves a Figma variable id to the DTCG path segments of its token. */
57
+ export type AliasResolver = (variableId: string) => string[]
58
+
59
+ /**
60
+ * Builds a DTCG group from one Figma collection's variables.
61
+ *
62
+ * Variables whose `variableCollectionId` does not match are skipped, so
63
+ * callers can pass the full variable list. Slash-separated names become
64
+ * nested objects (`font-family/heading` → `{ "font-family": { heading: {…} } }`).
65
+ * Values are read from the collection's `defaultModeId`; multi-mode
66
+ * support is deferred until DTCG token sets land.
67
+ *
68
+ * Aliases (`{ type: 'VARIABLE_ALIAS', id }`) become DTCG reference
69
+ * strings (`{group.sub.name}`). The default resolver looks the target
70
+ * up within `variables` by id; cross-collection callers pass a custom
71
+ * resolver that knows about other collections' DTCG path prefixes.
72
+ */
73
+ export function collectionToDtcg(
74
+ collection: FigmaCollection,
75
+ variables: FigmaVariable[],
76
+ resolveAlias: AliasResolver = defaultResolver(variables),
77
+ modeId: string = collection.defaultModeId,
78
+ exclude: ReadonlySet<string> = new Set(),
79
+ ): DtcgGroup {
80
+ const root: DtcgGroup = {}
81
+
82
+ for (const variable of variables) {
83
+ if (variable.variableCollectionId !== collection.id) continue
84
+ if (exclude.has(variable.name)) continue
85
+ const rawValue = variable.valuesByMode[modeId]
86
+ const token = buildToken(variable.resolvedType, rawValue, resolveAlias)
87
+ insertAt(root, variable.name.split('/'), token)
88
+ }
89
+
90
+ return root
91
+ }
92
+
93
+ /**
94
+ * Builds one DTCG output group per Figma collection.
95
+ *
96
+ * Single-mode collections (`Primitives`, `Interaction`) route directly to
97
+ * their own file. Multi-mode collections (`Primitives / Palette`, `Intent`,
98
+ * `Context`) are iterated per-mode, with the lowercase mode name as the
99
+ * top-level key in their file.
100
+ *
101
+ * Alias resolution uses each variable's natural name path so cross-collection
102
+ * references produce stable DTCG reference strings regardless of which file
103
+ * the variable lives in.
104
+ */
105
+ export function figmaVarsToDtcg(
106
+ collections: FigmaCollection[],
107
+ variables: FigmaVariable[],
108
+ ): DtcgFiles {
109
+ const files: DtcgFiles = {
110
+ primitives: {},
111
+ palette: {},
112
+ foreground: {},
113
+ intent: {},
114
+ context: {},
115
+ interaction: {},
116
+ }
117
+
118
+ const palette = collections.find((c) => c.name === 'Primitives / Palette')
119
+ const foreground = collections.find((c) => c.name === 'Primitives / Foreground')
120
+ const intent = collections.find((c) => c.name === 'Intent')
121
+ const context = collections.find((c) => c.name === 'Context')
122
+ const singleMode = collections.filter(
123
+ (c) => c.name === 'Primitives' || c.name === 'Interaction',
124
+ )
125
+
126
+ const routes = new Map<string, Routing>(
127
+ singleMode.map((c) => [c.id, routeCollection(c.name)]),
128
+ )
129
+
130
+ // All variables are addressable by their natural name path — no collection prefix.
131
+ // Variable names are unique across the system, so cross-collection alias references
132
+ // resolve correctly without knowing which file a variable lives in.
133
+ const variablePaths = new Map<string, string[]>()
134
+ for (const variable of variables) {
135
+ variablePaths.set(variable.id, variable.name.split('/'))
136
+ }
137
+
138
+ const resolveAlias: AliasResolver = (id) => {
139
+ const path = variablePaths.get(id)
140
+ if (!path) throw new Error(`Alias targets unknown variable: ${id}`)
141
+ return path
142
+ }
143
+
144
+ // Single-mode collections
145
+ for (const col of singleMode) {
146
+ const routing = routes.get(col.id)!
147
+ const group = collectionToDtcg(col, variables, resolveAlias)
148
+ mergeIntoPrefix(files[routing.file], routing.prefix, group)
149
+ }
150
+
151
+ // color/absolute-white and color/absolute-black are design-system constants
152
+ // that Harmoni never writes — exclude them from the palette backup.
153
+ const PALETTE_CONSTANTS: ReadonlySet<string> = new Set([
154
+ 'color/absolute-white',
155
+ 'color/absolute-black',
156
+ ])
157
+
158
+ // Primitives / Palette — per mode, lowercase mode name as top-level key
159
+ if (palette) {
160
+ for (const mode of palette.modes) {
161
+ const group = collectionToDtcg(palette, variables, resolveAlias, mode.modeId, PALETTE_CONSTANTS)
162
+ mergeIntoPrefix(files.palette, [mode.name.toLowerCase()], group)
163
+ }
164
+ }
165
+
166
+ // Primitives / Foreground — per mode; a thin alias layer over the palette
167
+ // carrying each ramp step's contrast-chosen foreground (RFC 0003).
168
+ if (foreground) {
169
+ for (const mode of foreground.modes) {
170
+ const group = collectionToDtcg(foreground, variables, resolveAlias, mode.modeId)
171
+ mergeIntoPrefix(files.foreground, [mode.name.toLowerCase()], group)
172
+ }
173
+ }
174
+
175
+ // Intent — per mode, lowercase mode name as top-level key
176
+ if (intent) {
177
+ for (const mode of intent.modes) {
178
+ const group = collectionToDtcg(intent, variables, resolveAlias, mode.modeId)
179
+ mergeIntoPrefix(files.intent, [mode.name.toLowerCase()], group)
180
+ }
181
+ }
182
+
183
+ // Context — per mode, lowercase mode name as top-level key
184
+ if (context) {
185
+ for (const mode of context.modes) {
186
+ const group = collectionToDtcg(context, variables, resolveAlias, mode.modeId)
187
+ mergeIntoPrefix(files.context, [mode.name.toLowerCase()], group)
188
+ }
189
+ }
190
+
191
+ return files
192
+ }
193
+
194
+ function routeCollection(name: string): Routing {
195
+ if (name === 'Primitives') return { file: 'primitives', prefix: [] }
196
+ if (name === 'Interaction') return { file: 'interaction', prefix: [] }
197
+ throw new Error(`Unrecognised collection name: "${name}"`)
198
+ }
199
+
200
+ function mergeIntoPrefix(
201
+ target: DtcgGroup,
202
+ prefix: string[],
203
+ source: DtcgGroup,
204
+ ): void {
205
+ let cursor = target
206
+ for (const key of prefix) {
207
+ if (cursor[key] === undefined) cursor[key] = {}
208
+ cursor = cursor[key] as DtcgGroup
209
+ }
210
+ Object.assign(cursor, source)
211
+ }
212
+
213
+ function defaultResolver(variables: FigmaVariable[]): AliasResolver {
214
+ return (id) => {
215
+ const target = variables.find((v) => v.id === id)
216
+ if (!target) {
217
+ throw new Error(`Alias targets unknown variable: ${id}`)
218
+ }
219
+ return target.name.split('/')
220
+ }
221
+ }
222
+
223
+ function buildToken(
224
+ type: FigmaResolvedType,
225
+ value: unknown,
226
+ resolveAlias: AliasResolver,
227
+ ): DtcgToken {
228
+ const $type = dtcgTypeOf(type)
229
+ const $value = isAlias(value)
230
+ ? `{${resolveAlias(value.id).join('.')}}`
231
+ : dtcgValueOf(type, value)
232
+ return { $type, $value }
233
+ }
234
+
235
+ function isAlias(v: unknown): v is FigmaAlias {
236
+ return (
237
+ typeof v === 'object' &&
238
+ v !== null &&
239
+ (v as { type?: unknown }).type === 'VARIABLE_ALIAS'
240
+ )
241
+ }
242
+
243
+ function dtcgTypeOf(type: FigmaResolvedType): DtcgType {
244
+ switch (type) {
245
+ case 'STRING':
246
+ return 'string'
247
+ case 'FLOAT':
248
+ return 'number'
249
+ case 'BOOLEAN':
250
+ return 'boolean'
251
+ case 'COLOR':
252
+ return 'color'
253
+ }
254
+ }
255
+
256
+ function dtcgValueOf(type: FigmaResolvedType, value: unknown): DtcgValue {
257
+ switch (type) {
258
+ case 'STRING':
259
+ return value as string
260
+ case 'FLOAT':
261
+ return value as number
262
+ case 'BOOLEAN':
263
+ return value as boolean
264
+ case 'COLOR':
265
+ return rgbaToHex(value as FigmaRgba)
266
+ }
267
+ }
268
+
269
+ function rgbaToHex({ r, g, b, a }: FigmaRgba): string {
270
+ const rr = channel(r)
271
+ const gg = channel(g)
272
+ const bb = channel(b)
273
+ if (a >= 1) return `#${rr}${gg}${bb}`
274
+ return `#${rr}${gg}${bb}${channel(a)}`
275
+ }
276
+
277
+ function channel(c: number): string {
278
+ return Math.round(c * 255)
279
+ .toString(16)
280
+ .padStart(2, '0')
281
+ }
282
+
283
+ function insertAt(root: DtcgGroup, path: string[], token: DtcgToken): void {
284
+ let cursor: DtcgGroup = root
285
+ for (let i = 0; i < path.length - 1; i++) {
286
+ const key = path[i]
287
+ const next = cursor[key]
288
+ if (next === undefined) {
289
+ const child: DtcgGroup = {}
290
+ cursor[key] = child
291
+ cursor = child
292
+ } else {
293
+ cursor = next as DtcgGroup
294
+ }
295
+ }
296
+ cursor[path[path.length - 1]] = token
297
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { collectionToDtcg, figmaVarsToDtcg } from './dtcg'
2
+ export type {
3
+ AliasResolver,
4
+ DtcgFiles,
5
+ DtcgGroup,
6
+ DtcgToken,
7
+ DtcgType,
8
+ DtcgValue,
9
+ FigmaAlias,
10
+ FigmaCollection,
11
+ FigmaResolvedType,
12
+ FigmaRgba,
13
+ FigmaVariable,
14
+ } from './dtcg'