@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/README.md +120 -0
- package/package.json +38 -0
- package/src/context.json +5314 -0
- package/src/dtcg.test.ts +619 -0
- package/src/dtcg.ts +297 -0
- package/src/index.ts +14 -0
- package/src/intent.json +486 -0
- package/src/interaction.json +32 -0
- package/src/palette.json +302 -0
- package/src/primitives.json +624 -0
- package/src/serve.ts +27 -0
- package/src/server.test.ts +159 -0
- package/src/server.ts +89 -0
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'
|