-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathvite.config.mts
More file actions
344 lines (316 loc) · 15.6 KB
/
Copy pathvite.config.mts
File metadata and controls
344 lines (316 loc) · 15.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
/// <reference types="vitest/config" />
import { defineConfig, type Plugin } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
import fs from 'fs/promises';
import { fileURLToPath } from 'node:url';
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
const DESIGN_SYSTEM_DIR = path.join(dirname, 'design-system');
// v2 cockpit I/O — the three files v2 components actually consume.
const V2_DIR = path.join(DESIGN_SYSTEM_DIR, 'v2');
const V2_PRIMITIVES = path.join(V2_DIR, 'primitives.css');
const V2_SEMANTICS = path.join(V2_DIR, 'semantics.css');
const V2_DARK = path.join(V2_DIR, 'themes', 'dark.css');
const V2_FONTS = path.join(V2_DIR, 'fonts.css');
const V2_DEFAULTS_DIR = path.join(DESIGN_SYSTEM_DIR, 'v2-defaults');
// Published v2 component sources — scanned for token usage by /api/token-usage.
const V2_COMPONENTS_DIR = path.join(dirname, 'components', 'v2');
// Token metadata (descriptions/categories/tags) — committed by /api/merge-metadata.
const METADATA_FILE = path.join(DESIGN_SYSTEM_DIR, 'tokens.metadata.json');
// Curated fallback for /api/google-fonts when GOOGLE_FONTS_API_KEY is unset — keeps the
// font picker usable (search + select) without the live catalog. Popular, broadly-useful
// families; the live API (when keyed) returns the full ~1800 sorted by popularity.
const GOOGLE_FONTS_FALLBACK = [
{ family: 'Inter', category: 'sans-serif' },
{ family: 'Roboto', category: 'sans-serif' },
{ family: 'Open Sans', category: 'sans-serif' },
{ family: 'Figtree', category: 'sans-serif' },
{ family: 'Poppins', category: 'sans-serif' },
{ family: 'Montserrat', category: 'sans-serif' },
{ family: 'Lato', category: 'sans-serif' },
{ family: 'Nunito', category: 'sans-serif' },
{ family: 'Work Sans', category: 'sans-serif' },
{ family: 'DM Sans', category: 'sans-serif' },
{ family: 'Manrope', category: 'sans-serif' },
{ family: 'Plus Jakarta Sans', category: 'sans-serif' },
{ family: 'Geist', category: 'sans-serif' },
{ family: 'Merriweather', category: 'serif' },
{ family: 'Playfair Display', category: 'serif' },
{ family: 'Lora', category: 'serif' },
{ family: 'Source Serif 4', category: 'serif' },
{ family: 'JetBrains Mono', category: 'monospace' },
{ family: 'Fira Code', category: 'monospace' },
{ family: 'IBM Plex Mono', category: 'monospace' },
{ family: 'Space Mono', category: 'monospace' },
{ family: 'Geist Mono', category: 'monospace' },
];
// Snapshot the three v2 files into v2-defaults/ once, lazily, before the first overwrite.
// Separate dir (not *.default.css siblings) so index.css can never @import a snapshot.
async function ensureV2DefaultsSnapshot(): Promise<void> {
try { await fs.access(V2_DEFAULTS_DIR); return; } catch { /* take it */ }
await fs.mkdir(V2_DEFAULTS_DIR, { recursive: true });
await Promise.all([
fs.copyFile(V2_PRIMITIVES, path.join(V2_DEFAULTS_DIR, 'primitives.css')),
fs.copyFile(V2_SEMANTICS, path.join(V2_DEFAULTS_DIR, 'semantics.css')),
fs.copyFile(V2_DARK, path.join(V2_DEFAULTS_DIR, 'dark.css')),
fs.copyFile(V2_FONTS, path.join(V2_DEFAULTS_DIR, 'fonts.css')),
]);
}
function tokenApiPlugin(): Plugin {
return {
name: 'geeklego-token-api',
async configureServer(server) {
// Regenerate the component catalog from the story files on dev-server
// start so the "Components" gallery always reflects the current set of
// v2 components (a new component appears without any manual step). The
// committed catalog.json is the fallback for tests/build where this
// plugin doesn't run.
try {
const { writeCatalog } = await import('./scripts/generate-catalog');
const entries = await writeCatalog(dirname);
server.config.logger.info(`geeklego: catalog regenerated (${entries.length} components)`);
} catch (e: any) {
server.config.logger.warn(`geeklego: catalog regeneration failed — ${e?.message ?? e}`);
}
server.middlewares.use(async (req, res, next) => {
const url = req.url?.split('?')[0];
if (url === '/api/load-tokens' && req.method === 'GET') {
try {
const { parseGeeklegoV2 } = await import('./app/src/utils/cssParser');
const [primCss, semCss, darkCss, fontsCss] = await Promise.all([
fs.readFile(V2_PRIMITIVES, 'utf-8'),
fs.readFile(V2_SEMANTICS, 'utf-8'),
fs.readFile(V2_DARK, 'utf-8'),
fs.readFile(V2_FONTS, 'utf-8').catch(() => ''),
]);
const tokens = parseGeeklegoV2(primCss, semCss, darkCss, fontsCss);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, tokens }));
} catch (e: any) {
res.setHeader('Content-Type', 'application/json');
res.statusCode = 500;
res.end(JSON.stringify({ success: false, error: e.message }));
}
return;
}
// Google Fonts catalog for the font picker. The API key (GOOGLE_FONTS_API_KEY) is read
// SERVER-SIDE only and never reaches the browser. When set, proxy the live Developer API
// (sorted by popularity); when unset OR the fetch fails, fall back to a curated list so
// the picker stays usable. Response: { success, source: 'google'|'fallback', fonts: [{family, category}] }.
if (url === '/api/google-fonts' && req.method === 'GET') {
const key = process.env.GOOGLE_FONTS_API_KEY;
const sendFallback = (note?: string) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, source: 'fallback', fonts: GOOGLE_FONTS_FALLBACK, note }));
};
if (!key) { sendFallback('GOOGLE_FONTS_API_KEY not set'); return; }
try {
const apiRes = await fetch(
`https://www.googleapis.com/webfonts/v1/webfonts?sort=popularity&key=${encodeURIComponent(key)}`,
);
if (!apiRes.ok) { sendFallback(`Google API ${apiRes.status}`); return; }
const data: any = await apiRes.json();
const fonts = (data.items ?? []).map((it: any) => ({ family: it.family, category: it.category }));
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, source: 'google', fonts }));
} catch (e: any) {
sendFallback(e?.message ?? 'fetch failed');
}
return;
}
// Scan the v2 component sources for where each token is used (var() refs + Tailwind
// utilities), so the Inspector's References panel reflects real component usage. Reads
// the three CSS files to derive the known token set, then scans components/v2.
if (url === '/api/token-usage' && req.method === 'GET') {
try {
const { scanTokenUsage } = await import('./scripts/scan-token-usage');
const [primCss, semCss, darkCss] = await Promise.all([
fs.readFile(V2_PRIMITIVES, 'utf-8'),
fs.readFile(V2_SEMANTICS, 'utf-8'),
fs.readFile(V2_DARK, 'utf-8'),
]);
const usage = await scanTokenUsage({
css: [primCss, semCss, darkCss].join('\n'),
componentsDir: V2_COMPONENTS_DIR,
repoRoot: dirname,
});
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, usage }));
} catch (e: any) {
res.setHeader('Content-Type', 'application/json');
res.statusCode = 500;
res.end(JSON.stringify({ success: false, error: e.message }));
}
return;
}
if (url === '/api/save-tokens' && req.method === 'POST') {
try {
const { generateGeeklegoV2 } = await import('./app/src/utils/cssGenerator');
const body: string = await new Promise((resolve, reject) => {
let d = ''; req.on('data', c => d += c); req.on('end', () => resolve(d)); req.on('error', reject);
});
const tokens = JSON.parse(body);
const out = generateGeeklegoV2(tokens);
await ensureV2DefaultsSnapshot();
await Promise.all([
fs.writeFile(V2_PRIMITIVES, out.primitives, 'utf-8'),
fs.writeFile(V2_SEMANTICS, out.semantics, 'utf-8'),
fs.writeFile(V2_DARK, out.dark, 'utf-8'),
fs.writeFile(V2_FONTS, out.fonts, 'utf-8'),
]);
if (server.hot) server.hot.send('geeklego:tokens-updated', {});
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true }));
} catch (e: any) {
res.setHeader('Content-Type', 'application/json');
res.statusCode = 500;
res.end(JSON.stringify({ success: false, error: e.message }));
}
return;
}
if (url === '/api/restore-default' && req.method === 'POST') {
try {
try { await fs.access(V2_DEFAULTS_DIR); } catch {
res.setHeader('Content-Type', 'application/json');
res.statusCode = 404;
res.end(JSON.stringify({ success: false, error: 'Default snapshot not found (no save yet)' }));
return;
}
const [prim, sem, dark, fonts] = await Promise.all([
fs.readFile(path.join(V2_DEFAULTS_DIR, 'primitives.css'), 'utf-8'),
fs.readFile(path.join(V2_DEFAULTS_DIR, 'semantics.css'), 'utf-8'),
fs.readFile(path.join(V2_DEFAULTS_DIR, 'dark.css'), 'utf-8'),
fs.readFile(path.join(V2_DEFAULTS_DIR, 'fonts.css'), 'utf-8').catch(() => ''),
]);
await Promise.all([
fs.writeFile(V2_PRIMITIVES, prim, 'utf-8'),
fs.writeFile(V2_SEMANTICS, sem, 'utf-8'),
fs.writeFile(V2_DARK, dark, 'utf-8'),
...(fonts ? [fs.writeFile(V2_FONTS, fonts, 'utf-8')] : []),
]);
if (server.hot) server.hot.send('geeklego:tokens-restored', {});
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true }));
} catch (e: any) {
res.setHeader('Content-Type', 'application/json');
res.statusCode = 500;
res.end(JSON.stringify({ success: false, error: e.message }));
}
return;
}
// Commit staged token-metadata edits (descriptions/categories/tags) into
// design-system/tokens.metadata.json. Body: { stagedChanges }. Reuses the same
// pure merge the cockpit uses, reads-modifies-writes the file, bumps lastUpdated.
if (url === '/api/merge-metadata' && req.method === 'POST') {
try {
const { mergeMetadataWithStaged, getDefaultMetadata, normalizeMetadata } =
await import('./app/src/state/metadataLoader');
const body: string = await new Promise((resolve, reject) => {
let d = ''; req.on('data', c => d += c); req.on('end', () => resolve(d)); req.on('error', reject);
});
const { stagedChanges } = JSON.parse(body || '{}');
let current;
try {
current = normalizeMetadata(JSON.parse(await fs.readFile(METADATA_FILE, 'utf-8')));
} catch {
current = getDefaultMetadata();
}
const merged = mergeMetadataWithStaged(current, stagedChanges ?? {});
merged.lastUpdated = new Date().toISOString();
await fs.writeFile(METADATA_FILE, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true }));
} catch (e: any) {
res.setHeader('Content-Type', 'application/json');
res.statusCode = 500;
res.end(JSON.stringify({ success: false, error: e.message }));
}
return;
}
if (url === '/api/sync-build' && req.method === 'POST') {
try {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
await execAsync('npm run sync-build', { cwd: dirname });
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true }));
} catch (e: any) {
res.setHeader('Content-Type', 'application/json');
res.statusCode = 500;
res.end(JSON.stringify({ success: false, error: e.message }));
}
return;
}
// Generate the W3C DTCG JSON IR by shelling out to the deterministic script
// (scripts/export-ir.ts reads design-system/v2/*.css on disk and writes dist/ir/tokens.json).
// Returns the freshly-written file contents so the cockpit can offer a download.
if (url === '/api/export-ir' && req.method === 'POST') {
try {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
await execAsync('npm run export-ir', { cwd: dirname });
const outPath = path.join(dirname, 'dist', 'ir', 'tokens.json');
const content = await fs.readFile(outPath, 'utf-8');
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, path: 'dist/ir/tokens.json', content }));
} catch (e: any) {
res.setHeader('Content-Type', 'application/json');
res.statusCode = 500;
res.end(JSON.stringify({ success: false, error: e.message }));
}
return;
}
// Generate the human-readable design.md by shelling out to the deterministic script.
// export-design-md consumes the IR, so we run export-ir first to keep the doc in sync
// with the on-disk tokens. Returns the file contents for download.
if (url === '/api/export-design-md' && req.method === 'POST') {
try {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
// design.md reads the IR — regenerate the IR first so it reflects the current tokens.
await execAsync('npm run export-ir', { cwd: dirname });
await execAsync('npm run export-design-md', { cwd: dirname });
const outPath = path.join(dirname, 'dist', 'ir', 'design-system.md');
const content = await fs.readFile(outPath, 'utf-8');
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, path: 'dist/ir/design-system.md', content }));
} catch (e: any) {
res.setHeader('Content-Type', 'application/json');
res.statusCode = 500;
res.end(JSON.stringify({ success: false, error: e.message }));
}
return;
}
next();
});
}
};
}
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
plugins: [
react(),
tailwindcss(),
tokenApiPlugin(),
],
css: {},
server: {
port: process.env.PORT ? Number(process.env.PORT) : 5176,
strictPort: false
},
test: {
projects: [
{
test: {
name: 'unit',
environment: 'node',
include: ['app/src/**/*.test.ts'],
}
}
]
}
});