@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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
|
|
7
|
+
import { createSyncServer } from './server'
|
|
8
|
+
|
|
9
|
+
describe('createSyncServer', () => {
|
|
10
|
+
let outDir: string
|
|
11
|
+
let server: http.Server
|
|
12
|
+
let port: number
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tokens-sync-'))
|
|
16
|
+
server = createSyncServer({ outDir })
|
|
17
|
+
await new Promise<void>((resolve) =>
|
|
18
|
+
server.listen(0, '127.0.0.1', () => resolve()),
|
|
19
|
+
)
|
|
20
|
+
const address = server.address()
|
|
21
|
+
if (typeof address === 'string' || address === null) {
|
|
22
|
+
throw new Error('server did not bind to a port')
|
|
23
|
+
}
|
|
24
|
+
port = address.port
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await new Promise<void>((resolve, reject) =>
|
|
29
|
+
server.close((err) => (err ? reject(err) : resolve())),
|
|
30
|
+
)
|
|
31
|
+
await fs.rm(outDir, { recursive: true, force: true })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
function url(path = '/sync'): string {
|
|
35
|
+
return `http://127.0.0.1:${port}${path}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it('writes the six DTCG files to disk on POST /sync', async () => {
|
|
39
|
+
const payload = {
|
|
40
|
+
primitives: {
|
|
41
|
+
'font-family': { sans: { $type: 'string', $value: 'Asta Sans' } },
|
|
42
|
+
},
|
|
43
|
+
palette: { light: {}, dark: {} },
|
|
44
|
+
foreground: { light: {}, dark: {} },
|
|
45
|
+
intent: { light: {}, dark: {} },
|
|
46
|
+
context: { comfortable: {} },
|
|
47
|
+
interaction: {},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const response = await fetch(url(), {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify(payload),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(response.status).toBe(200)
|
|
57
|
+
expect(
|
|
58
|
+
JSON.parse(
|
|
59
|
+
await fs.readFile(path.join(outDir, 'primitives.json'), 'utf8'),
|
|
60
|
+
),
|
|
61
|
+
).toEqual(payload.primitives)
|
|
62
|
+
expect(
|
|
63
|
+
JSON.parse(
|
|
64
|
+
await fs.readFile(path.join(outDir, 'palette.json'), 'utf8'),
|
|
65
|
+
),
|
|
66
|
+
).toEqual(payload.palette)
|
|
67
|
+
expect(
|
|
68
|
+
JSON.parse(
|
|
69
|
+
await fs.readFile(path.join(outDir, 'foreground.json'), 'utf8'),
|
|
70
|
+
),
|
|
71
|
+
).toEqual(payload.foreground)
|
|
72
|
+
expect(
|
|
73
|
+
JSON.parse(
|
|
74
|
+
await fs.readFile(path.join(outDir, 'intent.json'), 'utf8'),
|
|
75
|
+
),
|
|
76
|
+
).toEqual(payload.intent)
|
|
77
|
+
expect(
|
|
78
|
+
JSON.parse(
|
|
79
|
+
await fs.readFile(path.join(outDir, 'context.json'), 'utf8'),
|
|
80
|
+
),
|
|
81
|
+
).toEqual(payload.context)
|
|
82
|
+
expect(
|
|
83
|
+
JSON.parse(
|
|
84
|
+
await fs.readFile(path.join(outDir, 'interaction.json'), 'utf8'),
|
|
85
|
+
),
|
|
86
|
+
).toEqual(payload.interaction)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('includes the CORS Allow-Origin header on a successful sync', async () => {
|
|
90
|
+
const response = await fetch(url(), {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({ primitives: {}, palette: {}, intent: {}, context: {}, interaction: {} }),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(response.headers.get('access-control-allow-origin')).toBe('*')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('answers OPTIONS preflight with CORS headers and 204', async () => {
|
|
100
|
+
const response = await fetch(url(), { method: 'OPTIONS' })
|
|
101
|
+
|
|
102
|
+
expect(response.status).toBe(204)
|
|
103
|
+
expect(response.headers.get('access-control-allow-origin')).toBe('*')
|
|
104
|
+
expect(response.headers.get('access-control-allow-methods')).toContain(
|
|
105
|
+
'POST',
|
|
106
|
+
)
|
|
107
|
+
expect(response.headers.get('access-control-allow-headers')).toContain(
|
|
108
|
+
'Content-Type',
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('returns 400 when the request body is not valid JSON', async () => {
|
|
113
|
+
const response = await fetch(url(), {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: 'not json',
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect(response.status).toBe(400)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('returns 404 for paths other than /sync', async () => {
|
|
123
|
+
const response = await fetch(url('/other'), { method: 'POST' })
|
|
124
|
+
|
|
125
|
+
expect(response.status).toBe(404)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('returns 405 for unsupported methods on /sync', async () => {
|
|
129
|
+
const response = await fetch(url(), { method: 'GET' })
|
|
130
|
+
|
|
131
|
+
expect(response.status).toBe(405)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('writes pretty-printed JSON with a trailing newline', async () => {
|
|
135
|
+
const payload = {
|
|
136
|
+
primitives: {
|
|
137
|
+
color: { red: { $type: 'color', $value: '#ff0000' } },
|
|
138
|
+
},
|
|
139
|
+
palette: {},
|
|
140
|
+
intent: {},
|
|
141
|
+
context: {},
|
|
142
|
+
interaction: {},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await fetch(url(), {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: { 'Content-Type': 'application/json' },
|
|
148
|
+
body: JSON.stringify(payload),
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const written = await fs.readFile(
|
|
152
|
+
path.join(outDir, 'primitives.json'),
|
|
153
|
+
'utf8',
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
expect(written).toMatch(/\n$/)
|
|
157
|
+
expect(written).toContain(' "color"')
|
|
158
|
+
})
|
|
159
|
+
})
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny HTTP server that accepts a DTCG payload from the sync plugin and
|
|
3
|
+
* writes the three layer files into `outDir` atomically.
|
|
4
|
+
*
|
|
5
|
+
* Designed for local dev only: it binds to 127.0.0.1, replies with a
|
|
6
|
+
* permissive CORS allow-list, and has no auth. Run it via the
|
|
7
|
+
* `sync:serve` script — never expose it on a public interface.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import http from 'node:http'
|
|
11
|
+
import fs from 'node:fs/promises'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
|
|
14
|
+
import type { DtcgFiles } from './dtcg'
|
|
15
|
+
|
|
16
|
+
export interface SyncServerOptions {
|
|
17
|
+
/** Directory the three DTCG files are written into. */
|
|
18
|
+
outDir: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const CORS_HEADERS = {
|
|
22
|
+
'Access-Control-Allow-Origin': '*',
|
|
23
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
24
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
const DTCG_FILE_NAMES = ['primitives', 'palette', 'foreground', 'intent', 'context', 'interaction'] as const
|
|
28
|
+
|
|
29
|
+
/** Creates the HTTP server. Call `.listen(port)` to start accepting requests. */
|
|
30
|
+
export function createSyncServer(options: SyncServerOptions): http.Server {
|
|
31
|
+
return http.createServer((req, res) => {
|
|
32
|
+
void handleRequest(req, res, options)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function handleRequest(
|
|
37
|
+
req: http.IncomingMessage,
|
|
38
|
+
res: http.ServerResponse,
|
|
39
|
+
options: SyncServerOptions,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
if (req.method === 'OPTIONS') {
|
|
42
|
+
res.writeHead(204, CORS_HEADERS)
|
|
43
|
+
res.end()
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (req.url !== '/sync') {
|
|
48
|
+
res.writeHead(404, CORS_HEADERS)
|
|
49
|
+
res.end()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (req.method !== 'POST') {
|
|
54
|
+
res.writeHead(405, { ...CORS_HEADERS, Allow: 'POST, OPTIONS' })
|
|
55
|
+
res.end()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let body = ''
|
|
60
|
+
for await (const chunk of req) body += chunk
|
|
61
|
+
|
|
62
|
+
let payload: DtcgFiles
|
|
63
|
+
try {
|
|
64
|
+
payload = JSON.parse(body)
|
|
65
|
+
} catch {
|
|
66
|
+
res.writeHead(400, { ...CORS_HEADERS, 'Content-Type': 'application/json' })
|
|
67
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await writeDtcgFiles(payload, options.outDir)
|
|
72
|
+
|
|
73
|
+
res.writeHead(200, { ...CORS_HEADERS, 'Content-Type': 'application/json' })
|
|
74
|
+
res.end(JSON.stringify({ ok: true }))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function writeDtcgFiles(
|
|
78
|
+
files: DtcgFiles,
|
|
79
|
+
outDir: string,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
await fs.mkdir(outDir, { recursive: true })
|
|
82
|
+
for (const name of DTCG_FILE_NAMES) {
|
|
83
|
+
const target = path.join(outDir, `${name}.json`)
|
|
84
|
+
const tmp = `${target}.tmp`
|
|
85
|
+
const json = JSON.stringify(files[name] ?? {}, null, 2) + '\n'
|
|
86
|
+
await fs.writeFile(tmp, json, 'utf8')
|
|
87
|
+
await fs.rename(tmp, target)
|
|
88
|
+
}
|
|
89
|
+
}
|