@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.
@@ -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
+ }