@czap/vite 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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/dist/css-quantize.d.ts +53 -0
  4. package/dist/css-quantize.d.ts.map +1 -0
  5. package/dist/css-quantize.js +247 -0
  6. package/dist/css-quantize.js.map +1 -0
  7. package/dist/environments.d.ts +36 -0
  8. package/dist/environments.d.ts.map +1 -0
  9. package/dist/environments.js +67 -0
  10. package/dist/environments.js.map +1 -0
  11. package/dist/hmr.d.ts +37 -0
  12. package/dist/hmr.d.ts.map +1 -0
  13. package/dist/hmr.js +84 -0
  14. package/dist/hmr.js.map +1 -0
  15. package/dist/html-transform.d.ts +19 -0
  16. package/dist/html-transform.d.ts.map +1 -0
  17. package/dist/html-transform.js +54 -0
  18. package/dist/html-transform.js.map +1 -0
  19. package/dist/index.d.ts +51 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +43 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/normalize-css-eol.d.ts +7 -0
  24. package/dist/normalize-css-eol.d.ts.map +1 -0
  25. package/dist/normalize-css-eol.js +9 -0
  26. package/dist/normalize-css-eol.js.map +1 -0
  27. package/dist/plugin.d.ts +48 -0
  28. package/dist/plugin.d.ts.map +1 -0
  29. package/dist/plugin.js +404 -0
  30. package/dist/plugin.js.map +1 -0
  31. package/dist/primitive-resolve.d.ts +56 -0
  32. package/dist/primitive-resolve.d.ts.map +1 -0
  33. package/dist/primitive-resolve.js +71 -0
  34. package/dist/primitive-resolve.js.map +1 -0
  35. package/dist/resolve-fs.d.ts +13 -0
  36. package/dist/resolve-fs.d.ts.map +1 -0
  37. package/dist/resolve-fs.js +80 -0
  38. package/dist/resolve-fs.js.map +1 -0
  39. package/dist/resolve-utils.d.ts +20 -0
  40. package/dist/resolve-utils.d.ts.map +1 -0
  41. package/dist/resolve-utils.js +45 -0
  42. package/dist/resolve-utils.js.map +1 -0
  43. package/dist/style-transform.d.ts +49 -0
  44. package/dist/style-transform.d.ts.map +1 -0
  45. package/dist/style-transform.js +122 -0
  46. package/dist/style-transform.js.map +1 -0
  47. package/dist/theme-transform.d.ts +44 -0
  48. package/dist/theme-transform.d.ts.map +1 -0
  49. package/dist/theme-transform.js +85 -0
  50. package/dist/theme-transform.js.map +1 -0
  51. package/dist/token-transform.d.ts +42 -0
  52. package/dist/token-transform.d.ts.map +1 -0
  53. package/dist/token-transform.js +84 -0
  54. package/dist/token-transform.js.map +1 -0
  55. package/dist/virtual-modules.d.ts +55 -0
  56. package/dist/virtual-modules.d.ts.map +1 -0
  57. package/dist/virtual-modules.js +141 -0
  58. package/dist/virtual-modules.js.map +1 -0
  59. package/dist/wasm-resolve.d.ts +25 -0
  60. package/dist/wasm-resolve.d.ts.map +1 -0
  61. package/dist/wasm-resolve.js +36 -0
  62. package/dist/wasm-resolve.js.map +1 -0
  63. package/package.json +63 -0
  64. package/src/css-quantize.ts +294 -0
  65. package/src/environments.ts +98 -0
  66. package/src/hmr.ts +121 -0
  67. package/src/html-transform.ts +61 -0
  68. package/src/index.ts +71 -0
  69. package/src/normalize-css-eol.ts +8 -0
  70. package/src/plugin.ts +492 -0
  71. package/src/primitive-resolve.ts +106 -0
  72. package/src/resolve-fs.ts +82 -0
  73. package/src/resolve-utils.ts +54 -0
  74. package/src/style-transform.ts +157 -0
  75. package/src/theme-transform.ts +119 -0
  76. package/src/token-transform.ts +117 -0
  77. package/src/virtual-modules.ts +160 -0
  78. package/src/wasm-resolve.ts +54 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Main Vite 8 plugin for czap -- processes `@token`, `@theme`,
3
+ * `@style`, and `@quantize` CSS blocks, handles HMR, serves virtual
4
+ * modules, and configures build environments.
5
+ *
6
+ * Transform pipeline order: tokens -- themes -- styles -- quantize.
7
+ * This ordering ensures themes / styles can reference token custom
8
+ * properties that were already compiled earlier in the pipeline.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import type { Plugin } from 'vite';
15
+ import type { Boundary, Token, Theme, Style } from '@czap/core';
16
+ import { parseQuantizeBlocks, compileQuantizeBlock } from './css-quantize.js';
17
+ import { resolvePrimitive } from './primitive-resolve.js';
18
+ import { transformHTML } from './html-transform.js';
19
+ import { parseTokenBlocks, compileTokenBlock } from './token-transform.js';
20
+ import { parseThemeBlocks, compileThemeBlock } from './theme-transform.js';
21
+ import { parseStyleBlocks, compileStyleBlock } from './style-transform.js';
22
+ import { resolveVirtualId, loadVirtualModule } from './virtual-modules.js';
23
+ import { buildEnvironments, type CzapEnvironmentName } from './environments.js';
24
+ import { resolveWASM } from './wasm-resolve.js';
25
+ import { normalizeCssLineEndings } from './normalize-css-eol.js';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Configuration options for the {@link plugin} factory. Every field
33
+ * is optional; omitted values use convention-based defaults.
34
+ */
35
+ export interface PluginConfig {
36
+ /** Override source directories for each primitive kind. */
37
+ readonly dirs?: Partial<Record<'boundary' | 'token' | 'theme' | 'style', string>>;
38
+ /** Toggle surgical HMR emission (default `true`). */
39
+ readonly hmr?: boolean;
40
+ /** Named Vite environments to configure (browser / server / shader). */
41
+ readonly environments?: readonly ('browser' | 'server' | 'shader')[];
42
+ /** Opt-in WASM runtime configuration. */
43
+ readonly wasm?: { readonly enabled?: boolean; readonly path?: string };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Plugin
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Create the czap Vite plugin.
52
+ *
53
+ * Transforms CSS files containing `@token`, `@theme`, `@style`, and
54
+ * `@quantize` blocks into native CSS custom properties,
55
+ * `html[data-theme]` selectors, scoped `@layer` / `@scope` rules, and
56
+ * `@container` queries respectively. Uses convention-based definition
57
+ * resolution and provides HMR support for surgical CSS and shader
58
+ * uniform updates.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * // vite.config.ts
63
+ * import { plugin as czap } from '@czap/vite';
64
+ * const config = { plugins: [czap()] };
65
+ * ```
66
+ */
67
+ export function plugin(config?: PluginConfig): Plugin {
68
+ const hmrEnabled = config?.hmr !== false;
69
+ const wasmEnabled = config?.wasm?.enabled === true;
70
+ let projectRoot = process.cwd();
71
+ let isBuild = false;
72
+ let resolvedWasm: ReturnType<typeof resolveWASM> = null;
73
+ if (wasmEnabled) {
74
+ resolvedWasm = resolveWASM(projectRoot, config?.wasm?.path);
75
+ }
76
+ let emittedWasmRefId: string | null = null;
77
+
78
+ // Caches for resolved definitions to avoid re-importing on every transform
79
+ const boundaryCache = new Map<string, Boundary.Shape | null>();
80
+ const tokenCache = new Map<string, Token.Shape | null>();
81
+ const themeCache = new Map<string, Theme.Shape | null>();
82
+ const styleCache = new Map<string, Style.Shape | null>();
83
+
84
+ return {
85
+ name: '@czap/vite',
86
+ enforce: 'pre' as const,
87
+
88
+ configResolved(resolvedConfig) {
89
+ projectRoot = resolvedConfig.root;
90
+ isBuild = resolvedConfig.command === 'build';
91
+ resolvedWasm = null;
92
+ if (wasmEnabled) {
93
+ resolvedWasm = resolveWASM(projectRoot, config?.wasm?.path);
94
+ }
95
+ },
96
+
97
+ buildStart() {
98
+ if (!wasmEnabled) {
99
+ return;
100
+ }
101
+
102
+ resolvedWasm = resolveWASM(projectRoot, config?.wasm?.path);
103
+ if (!resolvedWasm) {
104
+ this.warn(
105
+ 'WASM support was enabled, but no czap-compute binary could be resolved. Runtime will fall back to TypeScript kernels.',
106
+ );
107
+ return;
108
+ }
109
+
110
+ if (isBuild) {
111
+ emittedWasmRefId = this.emitFile({
112
+ type: 'asset',
113
+ name: 'czap-compute.wasm',
114
+ source: readFileSync(resolvedWasm.filePath),
115
+ });
116
+ }
117
+ },
118
+
119
+ // -----------------------------------------------------------------------
120
+ // HMR client script injection
121
+ // -----------------------------------------------------------------------
122
+
123
+ transformIndexHtml() {
124
+ if (!hmrEnabled) return [];
125
+ return [
126
+ {
127
+ tag: 'script' as const,
128
+ attrs: { type: 'module' },
129
+ children: `import 'virtual:czap/hmr-client';`,
130
+ injectTo: 'head' as const,
131
+ },
132
+ ];
133
+ },
134
+
135
+ // -----------------------------------------------------------------------
136
+ // Virtual module resolution
137
+ // -----------------------------------------------------------------------
138
+
139
+ resolveId(id: string) {
140
+ return resolveVirtualId(id);
141
+ },
142
+
143
+ load(id: string) {
144
+ if (id === '\0virtual:czap/wasm-url') {
145
+ if (!wasmEnabled) {
146
+ return 'export const wasmUrl = null;';
147
+ }
148
+
149
+ if (!resolvedWasm) {
150
+ return 'export const wasmUrl = null;';
151
+ }
152
+
153
+ if (isBuild && emittedWasmRefId) {
154
+ return `export const wasmUrl = import.meta.ROLLUP_FILE_URL_${emittedWasmRefId};`;
155
+ }
156
+
157
+ const browserUrl =
158
+ resolvedWasm.source === 'public' ? '/czap-compute.wasm' : `/@fs/${resolvedWasm.filePath.replace(/\\/g, '/')}`;
159
+
160
+ return `export const wasmUrl = ${JSON.stringify(browserUrl)};`;
161
+ }
162
+
163
+ return loadVirtualModule(id);
164
+ },
165
+
166
+ // -----------------------------------------------------------------------
167
+ // CSS transform pipeline: tokens -> themes -> styles -> quantize
168
+ // -----------------------------------------------------------------------
169
+
170
+ async transform(code: string, id: string) {
171
+ if (id.endsWith('.html') || id.endsWith('.astro')) {
172
+ const transformed = await transformHTML(code, id, projectRoot);
173
+ if (transformed === code) {
174
+ return null;
175
+ }
176
+
177
+ return {
178
+ code: transformed,
179
+ map: null,
180
+ };
181
+ }
182
+
183
+ // Only process CSS files
184
+ if (!id.endsWith('.css')) return null;
185
+
186
+ // Quick check -- skip files with no @czap at-rules
187
+ const hasToken = code.includes('@token');
188
+ const hasTheme = code.includes('@theme');
189
+ const hasStyle = code.includes('@style');
190
+ const hasQuantize = code.includes('@quantize');
191
+
192
+ if (!hasToken && !hasTheme && !hasStyle && !hasQuantize) return null;
193
+
194
+ let transformed = normalizeCssLineEndings(code);
195
+
196
+ // ---- Phase 1: @token -> CSS custom properties + @property ----
197
+ if (hasToken) {
198
+ const tokenBlocks = parseTokenBlocks(transformed, id);
199
+
200
+ for (const block of tokenBlocks) {
201
+ const cacheKey = `${block.tokenName}:${id}`;
202
+ let token: Token.Shape | null | undefined = tokenCache.get(cacheKey);
203
+
204
+ if (token === undefined) {
205
+ const resolution = await resolvePrimitive('token', block.tokenName, id, projectRoot, config?.dirs?.token);
206
+ token = resolution?.primitive ?? null;
207
+ tokenCache.set(cacheKey, token);
208
+ }
209
+
210
+ if (token === null) {
211
+ this.warn(`Could not resolve token "${block.tokenName}" referenced in ${id}:${block.line}`);
212
+ continue;
213
+ }
214
+
215
+ const compiled = compileTokenBlock(block, token);
216
+ const blockSpan = findAtRuleBlock(transformed, '@token', block.tokenName);
217
+
218
+ if (blockSpan) {
219
+ transformed = transformed.substring(0, blockSpan.start) + compiled + transformed.substring(blockSpan.end);
220
+ }
221
+ }
222
+ }
223
+
224
+ // ---- Phase 2: @theme -> html[data-theme] selectors + transitions ----
225
+ if (hasTheme) {
226
+ const themeBlocks = parseThemeBlocks(transformed, id);
227
+
228
+ for (const block of themeBlocks) {
229
+ const cacheKey = `${block.themeName}:${id}`;
230
+ let theme: Theme.Shape | null | undefined = themeCache.get(cacheKey);
231
+
232
+ if (theme === undefined) {
233
+ const resolution = await resolvePrimitive('theme', block.themeName, id, projectRoot, config?.dirs?.theme);
234
+ theme = resolution?.primitive ?? null;
235
+ themeCache.set(cacheKey, theme);
236
+ }
237
+
238
+ if (theme === null) {
239
+ this.warn(`Could not resolve theme "${block.themeName}" referenced in ${id}:${block.line}`);
240
+ continue;
241
+ }
242
+
243
+ const compiled = compileThemeBlock(block, theme);
244
+ const blockSpan = findAtRuleBlock(transformed, '@theme', block.themeName);
245
+
246
+ if (blockSpan) {
247
+ transformed = transformed.substring(0, blockSpan.start) + compiled + transformed.substring(blockSpan.end);
248
+ }
249
+ }
250
+ }
251
+
252
+ // ---- Phase 3: @style -> scoped CSS with @layer/@scope/@starting-style ----
253
+ if (hasStyle) {
254
+ const styleBlocks = parseStyleBlocks(transformed, id);
255
+
256
+ for (const block of styleBlocks) {
257
+ const cacheKey = `${block.styleName}:${id}`;
258
+ let style: Style.Shape | null | undefined = styleCache.get(cacheKey);
259
+
260
+ if (style === undefined) {
261
+ const resolution = await resolvePrimitive('style', block.styleName, id, projectRoot, config?.dirs?.style);
262
+ style = resolution?.primitive ?? null;
263
+ styleCache.set(cacheKey, style);
264
+ }
265
+
266
+ if (style === null) {
267
+ this.warn(`Could not resolve style "${block.styleName}" referenced in ${id}:${block.line}`);
268
+ continue;
269
+ }
270
+
271
+ const compiled = compileStyleBlock(block, style);
272
+ const blockSpan = findAtRuleBlock(transformed, '@style', block.styleName);
273
+
274
+ if (blockSpan) {
275
+ transformed = transformed.substring(0, blockSpan.start) + compiled + transformed.substring(blockSpan.end);
276
+ }
277
+ }
278
+ }
279
+
280
+ // ---- Phase 4: @quantize -> @container queries (existing) ----
281
+ if (hasQuantize) {
282
+ const quantizeBlocks = parseQuantizeBlocks(transformed, id);
283
+
284
+ for (const block of quantizeBlocks) {
285
+ const cacheKey = `${block.boundaryName}:${id}`;
286
+ let boundary: Boundary.Shape | null | undefined = boundaryCache.get(cacheKey);
287
+
288
+ if (boundary === undefined) {
289
+ const resolution = await resolvePrimitive('boundary', block.boundaryName, id, projectRoot, config?.dirs?.boundary);
290
+ boundary = resolution?.primitive ?? null;
291
+ boundaryCache.set(cacheKey, boundary);
292
+ }
293
+
294
+ if (boundary === null) {
295
+ this.warn(`Could not resolve boundary "${block.boundaryName}" referenced in ${id}:${block.line}`);
296
+ continue;
297
+ }
298
+
299
+ const compiled = compileQuantizeBlock(block, boundary);
300
+ const blockSpan = findAtRuleBlock(transformed, '@quantize', block.boundaryName);
301
+
302
+ if (blockSpan) {
303
+ transformed = transformed.substring(0, blockSpan.start) + compiled + transformed.substring(blockSpan.end);
304
+ }
305
+ }
306
+ }
307
+
308
+ if (transformed === code) return null;
309
+
310
+ return {
311
+ code: transformed,
312
+ map: null,
313
+ };
314
+ },
315
+
316
+ // -----------------------------------------------------------------------
317
+ // HMR: invalidate caches + re-transform on definition file changes
318
+ // -----------------------------------------------------------------------
319
+
320
+ hotUpdate(options) {
321
+ if (!hmrEnabled) return;
322
+
323
+ const file = options.file;
324
+
325
+ // Invalidate definition caches when source files change
326
+ const isDefFile =
327
+ file.endsWith('.boundaries.ts') ||
328
+ file.endsWith('/boundaries.ts') ||
329
+ file.endsWith('.tokens.ts') ||
330
+ file.endsWith('/tokens.ts') ||
331
+ file.endsWith('.themes.ts') ||
332
+ file.endsWith('/themes.ts') ||
333
+ file.endsWith('.styles.ts') ||
334
+ file.endsWith('/styles.ts');
335
+
336
+ if (isDefFile) {
337
+ // Clear all caches since definitions may cross-reference
338
+ boundaryCache.clear();
339
+ tokenCache.clear();
340
+ themeCache.clear();
341
+ styleCache.clear();
342
+
343
+ const moduleGraph = this.environment.moduleGraph;
344
+ const transformModules = Array.from(moduleGraph.idToModuleMap.values()).filter((mod) => {
345
+ const moduleId = mod.id;
346
+ return (
347
+ typeof moduleId === 'string' &&
348
+ (moduleId.endsWith('.css') || moduleId.endsWith('.astro') || moduleId.endsWith('.html'))
349
+ );
350
+ });
351
+
352
+ if (transformModules.length > 0) {
353
+ return transformModules;
354
+ }
355
+ }
356
+
357
+ if (file.endsWith('.css') || file.endsWith('.astro') || file.endsWith('.html')) {
358
+ const moduleGraph = this.environment.moduleGraph;
359
+ const mod = moduleGraph.getModuleById(file);
360
+ if (mod) {
361
+ return [mod];
362
+ }
363
+ }
364
+
365
+ return;
366
+ },
367
+
368
+ config() {
369
+ if (!config?.environments || config.environments.length === 0) return {};
370
+
371
+ const envNames = config.environments as readonly CzapEnvironmentName[];
372
+ const envs = buildEnvironments(envNames);
373
+
374
+ return {
375
+ environments: envs,
376
+ };
377
+ },
378
+ };
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Helpers
383
+ // ---------------------------------------------------------------------------
384
+
385
+ /**
386
+ * Find the full span of an at-rule block in CSS source.
387
+ * Returns the start/end character offsets, or null if not found.
388
+ *
389
+ * Works for any at-rule pattern: `@token`, `@theme`, `@style`,
390
+ * `@quantize`. Uses a state machine to correctly skip block comments,
391
+ * quoted strings, and `url(...)` tokens (which may contain braces in
392
+ * data URIs) before counting brace depth.
393
+ */
394
+ function findAtRuleBlock(css: string, marker: string, name: string): { start: number; end: number } | null {
395
+ let searchFrom = 0;
396
+
397
+ while (searchFrom < css.length) {
398
+ const idx = css.indexOf(marker, searchFrom);
399
+ if (idx === -1) return null;
400
+
401
+ // Verify this at-rule is followed by the target name
402
+ const afterMarker = css.substring(idx + marker.length).trimStart();
403
+ if (!afterMarker.startsWith(name)) {
404
+ searchFrom = idx + marker.length;
405
+ continue;
406
+ }
407
+
408
+ // Ensure the name isn't just a prefix of a longer identifier
409
+ const charAfterName = afterMarker[name.length];
410
+ if (charAfterName !== undefined && /[a-zA-Z0-9_-]/.test(charAfterName)) {
411
+ searchFrom = idx + marker.length;
412
+ continue;
413
+ }
414
+
415
+ // Find the opening brace
416
+ const braceStart = css.indexOf('{', idx);
417
+ /* v8 ignore next — unreachable under real call sites: `findAtRuleBlock` runs only
418
+ after `parseTokenBlocks`/etc. matched a `@marker name { ... }` block with braces,
419
+ so the `{` is always still present in the transformed source. Defensive against
420
+ future multi-phase edits that strip braces between parse and lookup. */
421
+ if (braceStart === -1) return null;
422
+
423
+ // Walk forward tracking depth with full comment/string/url awareness
424
+ let depth = 1;
425
+ let pos = braceStart + 1;
426
+
427
+ while (pos < css.length && depth > 0) {
428
+ const ch = css[pos]!;
429
+
430
+ // Skip block comments: /* ... */
431
+ if (ch === '/' && css[pos + 1] === '*') {
432
+ pos += 2;
433
+ while (pos < css.length - 1 && !(css[pos] === '*' && css[pos + 1] === '/')) {
434
+ pos++;
435
+ }
436
+ pos += 2;
437
+ continue;
438
+ }
439
+
440
+ // Skip quoted strings: "..." and '...' (with backslash escapes)
441
+ if (ch === '"' || ch === "'") {
442
+ const quote = ch;
443
+ pos++;
444
+ while (pos < css.length && css[pos] !== quote) {
445
+ if (css[pos] === '\\') pos++;
446
+ pos++;
447
+ }
448
+ pos++;
449
+ continue;
450
+ }
451
+
452
+ // Skip url(...) tokens: may contain unquoted data URIs with braces
453
+ if (ch === 'u' && css.slice(pos, pos + 4).toLowerCase() === 'url(') {
454
+ pos += 4;
455
+ // url() may use a quoted or unquoted value
456
+ if (css[pos] === '"' || css[pos] === "'") {
457
+ const quote = css[pos]!;
458
+ pos++;
459
+ while (pos < css.length && css[pos] !== quote) {
460
+ if (css[pos] === '\\') pos++;
461
+ pos++;
462
+ }
463
+ pos++; // closing quote
464
+ } else {
465
+ // unquoted -- scan until the matching ')'
466
+ let parenDepth = 1;
467
+ while (pos < css.length && parenDepth > 0) {
468
+ if (css[pos] === '(') parenDepth++;
469
+ else if (css[pos] === ')') parenDepth--;
470
+ pos++;
471
+ }
472
+ }
473
+ continue;
474
+ }
475
+
476
+ if (ch === '{') depth++;
477
+ else if (ch === '}') depth--;
478
+ pos++;
479
+ }
480
+
481
+ if (depth === 0) {
482
+ return { start: idx, end: pos };
483
+ }
484
+ return null;
485
+ }
486
+ /* v8 ignore next — unreachable under real call sites: the inner `while` only runs
487
+ when `parseTokenBlocks` has already matched a `@marker name { ... }` block, so the
488
+ first indexOf hit returns either a `{start,end}` span or null inside the loop.
489
+ This terminal `return null` is a defense against pathological CSS where the
490
+ marker+name hits but searchFrom exhausts without a `{` match. */
491
+ return null;
492
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Generic primitive resolver -- replaces boundary-resolve,
3
+ * token-resolve, theme-resolve, and style-resolve with a single
4
+ * parameterised implementation.
5
+ *
6
+ * Resolution order for each kind:
7
+ *
8
+ * 1. `userDir/kinds.ts` (if `userDir` provided)
9
+ * 2. `userDir/*.kinds.ts` (if `userDir` provided)
10
+ * 3. `fromFile`'s dir / `kinds.ts`
11
+ * 4. `fromFile`'s dir / `*.kinds.ts`
12
+ * 5. `projectRoot/kinds.ts`
13
+ * 6. `projectRoot/*.kinds.ts`
14
+ * 7. `null`
15
+ *
16
+ * @module
17
+ */
18
+
19
+ import type { Boundary, Token, Theme, Style } from '@czap/core';
20
+ import type { PrimitiveKind } from '@czap/core';
21
+ import * as path from 'node:path';
22
+ import { fileExists, findConventionFiles } from './resolve-fs.js';
23
+ import { tryImportNamed } from './resolve-utils.js';
24
+
25
+ export type { PrimitiveKind };
26
+
27
+ /**
28
+ * Map a {@link PrimitiveKind} to the structural type of the primitive
29
+ * it resolves (`Boundary.Shape`, `Token.Shape`, ...).
30
+ */
31
+ export type PrimitiveShape<K extends PrimitiveKind> =
32
+ K extends 'boundary' ? Boundary.Shape :
33
+ K extends 'token' ? Token.Shape :
34
+ K extends 'theme' ? Theme.Shape :
35
+ Style.Shape;
36
+
37
+ /**
38
+ * A successful primitive resolution: the loaded primitive plus the
39
+ * absolute path of the module it came from (surfaced in diagnostics).
40
+ */
41
+ export interface PrimitiveResolution<K extends PrimitiveKind> {
42
+ readonly primitive: PrimitiveShape<K>;
43
+ readonly source: string;
44
+ }
45
+
46
+ /**
47
+ * Per-`PrimitiveKind` metadata used by {@link resolvePrimitive}:
48
+ * canonical filename, wildcard suffix, and the exported tag name the
49
+ * module is expected to decorate its primitives with.
50
+ */
51
+ export const KIND_META: Record<PrimitiveKind, { file: string; suffix: string; tag: string }> = {
52
+ boundary: { file: 'boundaries.ts', suffix: '.boundaries.ts', tag: 'BoundaryDef' },
53
+ token: { file: 'tokens.ts', suffix: '.tokens.ts', tag: 'TokenDef' },
54
+ theme: { file: 'themes.ts', suffix: '.themes.ts', tag: 'ThemeDef' },
55
+ style: { file: 'styles.ts', suffix: '.styles.ts', tag: 'StyleDef' },
56
+ };
57
+
58
+ /**
59
+ * Resolve a named primitive (boundary / token / theme / style) by
60
+ * walking the convention-based search order. Returns `null` when no
61
+ * module exports a matching named value.
62
+ *
63
+ * @param kind - Primitive kind to resolve.
64
+ * @param name - Named export to look up.
65
+ * @param fromFile - Path of the file that triggered the lookup.
66
+ * @param projectRoot - Vite project root (search fallback).
67
+ * @param userDir - Optional override directory (searched first).
68
+ */
69
+ export async function resolvePrimitive<K extends PrimitiveKind>(
70
+ kind: K,
71
+ name: string,
72
+ fromFile: string,
73
+ projectRoot: string,
74
+ userDir?: string,
75
+ ): Promise<PrimitiveResolution<K> | null> {
76
+ const { file, suffix, tag } = KIND_META[kind];
77
+ const diagnosticSource = `czap/vite.${kind}-resolve`;
78
+ const sourceDir = path.dirname(fromFile);
79
+
80
+ const searchDirs: string[] = [];
81
+ if (userDir) searchDirs.push(userDir);
82
+ if (sourceDir !== projectRoot) searchDirs.push(sourceDir);
83
+ searchDirs.push(projectRoot);
84
+
85
+ for (const dir of searchDirs) {
86
+ // Try direct convention file: boundaries.ts / tokens.ts / etc.
87
+ const directFile = path.join(dir, file);
88
+ if (fileExists(directFile, diagnosticSource)) {
89
+ const result = await tryImportNamed<PrimitiveShape<K>>(
90
+ directFile, name, tag, diagnosticSource, kind,
91
+ );
92
+ if (result !== undefined) return { primitive: result, source: directFile };
93
+ }
94
+
95
+ // Try wildcard files: *.boundaries.ts / *.tokens.ts / etc.
96
+ const wildcardFiles = findConventionFiles(dir, suffix, diagnosticSource);
97
+ for (const wildcardFile of wildcardFiles) {
98
+ const result = await tryImportNamed<PrimitiveShape<K>>(
99
+ wildcardFile, name, tag, diagnosticSource, kind,
100
+ );
101
+ if (result !== undefined) return { primitive: result, source: wildcardFile };
102
+ }
103
+ }
104
+
105
+ return null;
106
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Filesystem helpers for convention-based primitive resolution.
3
+ *
4
+ * Wraps `fs.statSync` / `fs.readdirSync` so missing files / directories
5
+ * are reported as `false` / `[]` instead of exceptions, while unexpected
6
+ * errors are routed through `Diagnostics.warn` and re-thrown.
7
+ *
8
+ * @module
9
+ */
10
+ import { Diagnostics } from '@czap/core';
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+
14
+ type FsError = NodeJS.ErrnoException;
15
+
16
+ function isMissingFilesystemError(error: unknown): error is FsError {
17
+ /* v8 ignore next — Node's fs APIs always throw objects (Error subclasses); the
18
+ non-object/null guards are defense-in-depth for a narrowed `unknown` and are
19
+ unreachable in practice without a host patching the fs module to throw primitives. */
20
+ if (typeof error !== 'object' || error === null || !('code' in error)) return false;
21
+ const code = (error as FsError).code;
22
+ return code === 'ENOENT' || code === 'ENOTDIR';
23
+ }
24
+
25
+ /**
26
+ * Return `true` when `filePath` points at a regular file. Missing paths
27
+ * return `false`; other filesystem errors are logged via
28
+ * `Diagnostics.warn` and re-thrown.
29
+ */
30
+ export function fileExists(filePath: string, source: string): boolean {
31
+ let exists = false;
32
+ let missing = false;
33
+ try {
34
+ exists = fs.statSync(filePath).isFile();
35
+ } catch (error) {
36
+ if (isMissingFilesystemError(error)) {
37
+ missing = true;
38
+ } else {
39
+ Diagnostics.warn({
40
+ source,
41
+ code: 'filesystem-stat-failed',
42
+ message: `Failed to stat "${filePath}" while resolving a convention module.`,
43
+ cause: error,
44
+ });
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ return missing ? false : exists;
50
+ }
51
+
52
+ /**
53
+ * List files in `dir` whose names end with `suffix` (e.g.
54
+ * `.boundaries.ts`). Missing directories return `[]`; other errors are
55
+ * logged and re-thrown.
56
+ */
57
+ export function findConventionFiles(dir: string, suffix: string, source: string): readonly string[] {
58
+ let entries: readonly string[] = [];
59
+ let missing = false;
60
+ try {
61
+ entries = fs.readdirSync(dir, { encoding: 'utf8' });
62
+ } catch (error) {
63
+ if (isMissingFilesystemError(error)) {
64
+ missing = true;
65
+ } else {
66
+ Diagnostics.warn({
67
+ source,
68
+ code: 'filesystem-readdir-failed',
69
+ message: `Failed to read "${dir}" while resolving "${suffix}" convention modules.`,
70
+ cause: error,
71
+ detail: { suffix },
72
+ });
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ if (missing) {
78
+ return [];
79
+ }
80
+
81
+ return entries.filter((entry: string) => entry.endsWith(suffix)).map((entry: string) => path.join(dir, entry));
82
+ }