@macroforge/vite-plugin 0.1.75 → 0.1.77

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 (2) hide show
  1. package/package.json +3 -3
  2. package/src/index.js +328 -6
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "dependencies": {
3
- "@macroforge/shared": "^0.1.75",
4
- "macroforge": "^0.1.75"
3
+ "@macroforge/shared": "^0.1.77",
4
+ "macroforge": "^0.1.77"
5
5
  },
6
6
  "devDependencies": {
7
7
  "@types/node": "^22.0.0"
@@ -30,5 +30,5 @@
30
30
  },
31
31
  "type": "module",
32
32
  "types": "src/index.d.ts",
33
- "version": "0.1.75"
33
+ "version": "0.1.77"
34
34
  }
package/src/index.js CHANGED
@@ -31,6 +31,7 @@
31
31
  * typesOutputDir: ".macroforge/types", // Types output dir (default: ".macroforge/types")
32
32
  * emitMetadata: true, // Emit metadata JSON (default: true)
33
33
  * metadataOutputDir: ".macroforge/meta", // Metadata output dir (default: ".macroforge/meta")
34
+ * devCache: true, // Disk cache for dev mode (default: true)
34
35
  * },
35
36
  * };
36
37
  * ```
@@ -39,6 +40,7 @@
39
40
  */
40
41
 
41
42
  import { createRequire } from "node:module";
43
+ import { createHash } from "node:crypto";
42
44
  import * as fs from "node:fs";
43
45
  import * as path from "node:path";
44
46
  import {
@@ -329,10 +331,17 @@ function emitDeclarationsFromCode(code, fileName, projectRoot) {
329
331
  export async function macroforge() {
330
332
  /**
331
333
  * Reference to the loaded Macroforge Rust binary module.
332
- * @type {{ expandSync: Function, loadConfig?: (content: string, filepath: string) => any } | undefined}
334
+ * @type {{ expandSync: Function, loadConfig?: (content: string, filepath: string) => any, scanProjectSync?: Function } | undefined}
333
335
  */
334
336
  let rustTransformer;
335
337
 
338
+ /**
339
+ * Cached type registry JSON from project scanning.
340
+ * Built during `buildStart` and passed to every `expandSync` call.
341
+ * @type {string | undefined}
342
+ */
343
+ let typeRegistryJson;
344
+
336
345
  // Load the Rust binary first
337
346
  try {
338
347
  const projectRequire = createRequire(process.cwd() + "/");
@@ -366,6 +375,8 @@ export async function macroforge() {
366
375
  let emitMetadata = true;
367
376
  /** @type {string} */
368
377
  let metadataOutputDir = ".macroforge/meta";
378
+ /** @type {boolean} */
379
+ let devCacheEnabled = true;
369
380
 
370
381
  // Load vite-specific options from the config file
371
382
  if (macroConfig.configPath) {
@@ -387,6 +398,9 @@ export async function macroforge() {
387
398
  if (viteConfig.metadataOutputDir !== undefined) {
388
399
  metadataOutputDir = viteConfig.metadataOutputDir;
389
400
  }
401
+ if (viteConfig.devCache !== undefined) {
402
+ devCacheEnabled = viteConfig.devCache;
403
+ }
390
404
  }
391
405
  } catch (error) {
392
406
  throw new Error(
@@ -398,6 +412,20 @@ export async function macroforge() {
398
412
  /** @type {string} */
399
413
  let projectRoot;
400
414
 
415
+ // --- Dev cache state ---
416
+ /** @type {boolean} */
417
+ let isDevMode = false;
418
+ /** @type {string | undefined} */
419
+ let cacheDir;
420
+ /** @type {{ version: string, configHash: string, entries: Record<string, { sourceHash: string, hasMacros: boolean }> } | null} */
421
+ let cacheManifest = null;
422
+ /** @type {string} */
423
+ let macroforgeVersion = "unknown";
424
+ /** @type {boolean} */
425
+ let cacheManifestDirty = false;
426
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
427
+ let manifestFlushTimer;
428
+
401
429
  /**
402
430
  * Ensures a directory exists, creating it recursively if necessary.
403
431
  * @param {string} dir
@@ -408,6 +436,185 @@ export async function macroforge() {
408
436
  }
409
437
  }
410
438
 
439
+ // --- Dev cache helpers ---
440
+
441
+ /**
442
+ * Computes SHA-256 hash of a string, returned as hex.
443
+ * @param {string} content
444
+ * @returns {string}
445
+ */
446
+ function contentHash(content) {
447
+ return createHash("sha256").update(content).digest("hex");
448
+ }
449
+
450
+ /**
451
+ * Reads the installed macroforge NAPI package version.
452
+ * Resolves the module's main entry point, then reads package.json
453
+ * from the same directory (avoids exports-map restrictions).
454
+ * @returns {string}
455
+ */
456
+ function getMacroforgeVersion() {
457
+ try {
458
+ const req = createRequire(process.cwd() + "/");
459
+ const mainPath = req.resolve("macroforge");
460
+ const pkgDir = path.dirname(mainPath);
461
+ const pkgJson = JSON.parse(
462
+ fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"),
463
+ );
464
+ return pkgJson.version;
465
+ } catch {
466
+ return "unknown";
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Computes a hash of the macroforge config file for cache invalidation.
472
+ * @returns {string}
473
+ */
474
+ function getConfigHash() {
475
+ if (macroConfig.configPath) {
476
+ try {
477
+ return contentHash(fs.readFileSync(macroConfig.configPath, "utf-8"));
478
+ } catch {
479
+ // config file disappeared
480
+ }
481
+ }
482
+ return "none";
483
+ }
484
+
485
+ /**
486
+ * Loads and validates the cache manifest from disk.
487
+ * Returns null if the cache is stale (version or config mismatch).
488
+ * @returns {{ version: string, configHash: string, entries: Record<string, { sourceHash: string, hasMacros: boolean }> } | null}
489
+ */
490
+ function loadCacheManifest() {
491
+ const manifestPath = path.join(cacheDir, "manifest.json");
492
+ if (!fs.existsSync(manifestPath)) return null;
493
+
494
+ try {
495
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
496
+
497
+ if (manifest.version !== macroforgeVersion) {
498
+ console.log(
499
+ "[@macroforge/vite-plugin] Cache invalidated: macroforge version changed",
500
+ );
501
+ return null;
502
+ }
503
+
504
+ const currentConfigHash = getConfigHash();
505
+ if (manifest.configHash !== currentConfigHash) {
506
+ console.log(
507
+ "[@macroforge/vite-plugin] Cache invalidated: config changed",
508
+ );
509
+ return null;
510
+ }
511
+
512
+ // Reject caches built with --builtin-only since they may lack external macro expansions
513
+ if (manifest.builtinOnly) {
514
+ console.log(
515
+ "[@macroforge/vite-plugin] Cache invalidated: built with --builtin-only (run without --builtin-only for full expansion)",
516
+ );
517
+ return null;
518
+ }
519
+
520
+ return manifest;
521
+ } catch {
522
+ return null;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Reads a cached expansion result for a source file.
528
+ * @param {string} id - Absolute file path
529
+ * @param {string} code - Current source code content
530
+ * @returns {{ code: string } | null}
531
+ */
532
+ function readCacheEntry(id, code) {
533
+ if (!cacheManifest || !cacheDir) return null;
534
+
535
+ const relPath = path.relative(projectRoot, id);
536
+ const entry = cacheManifest.entries[relPath];
537
+ if (!entry || !entry.hasMacros) return null;
538
+
539
+ const currentHash = contentHash(code);
540
+ if (entry.sourceHash !== currentHash) return null;
541
+
542
+ const cachePath = path.join(cacheDir, relPath + ".cache");
543
+ try {
544
+ const expandedCode = fs.readFileSync(cachePath, "utf-8");
545
+ return { code: expandedCode };
546
+ } catch {
547
+ return null;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Writes a cache entry after macro expansion.
553
+ * Only caches files that actually had macros expanded.
554
+ * @param {string} id - Absolute file path
555
+ * @param {string} sourceCode - Original source code
556
+ * @param {string} expandedCode - Expanded code from rustTransformer
557
+ * @param {boolean} hasMacros - Whether the file actually had macros expanded
558
+ */
559
+ function writeCacheEntry(id, sourceCode, expandedCode, hasMacros) {
560
+ if (!cacheDir) return;
561
+
562
+ const relPath = path.relative(projectRoot, id);
563
+
564
+ try {
565
+ // Only write .cache files for files that actually have macros
566
+ if (hasMacros) {
567
+ const cachePath = path.join(cacheDir, relPath + ".cache");
568
+ ensureDir(path.dirname(cachePath));
569
+ fs.writeFileSync(cachePath, expandedCode, "utf-8");
570
+ }
571
+
572
+ if (!cacheManifest) {
573
+ cacheManifest = {
574
+ version: macroforgeVersion,
575
+ configHash: getConfigHash(),
576
+ entries: {},
577
+ };
578
+ }
579
+
580
+ cacheManifest.entries[relPath] = {
581
+ sourceHash: contentHash(sourceCode),
582
+ hasMacros,
583
+ };
584
+
585
+ // Debounce manifest writes — don't write 59KB JSON on every file
586
+ cacheManifestDirty = true;
587
+ if (manifestFlushTimer) clearTimeout(manifestFlushTimer);
588
+ manifestFlushTimer = setTimeout(flushCacheManifest, 500);
589
+ } catch (error) {
590
+ console.warn(
591
+ `[@macroforge/vite-plugin] Failed to write cache for ${relPath}:`,
592
+ error.message,
593
+ );
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Flushes the dirty cache manifest to disk.
599
+ */
600
+ function flushCacheManifest() {
601
+ if (!cacheManifestDirty || !cacheManifest || !cacheDir) return;
602
+ try {
603
+ ensureDir(cacheDir);
604
+ fs.writeFileSync(
605
+ path.join(cacheDir, "manifest.json"),
606
+ JSON.stringify(cacheManifest, null, 2),
607
+ "utf-8",
608
+ );
609
+ cacheManifestDirty = false;
610
+ } catch (error) {
611
+ console.warn(
612
+ `[@macroforge/vite-plugin] Failed to write cache manifest:`,
613
+ error.message,
614
+ );
615
+ }
616
+ }
617
+
411
618
  /**
412
619
  * Writes generated TypeScript declaration files to the configured output directory.
413
620
  * @param {string} id - The absolute path of the source file
@@ -498,10 +705,63 @@ export async function macroforge() {
498
705
  enforce: "pre",
499
706
 
500
707
  /**
501
- * @param {{ root: string }} config
708
+ * @param {{ root: string, command: string }} config
502
709
  */
503
710
  configResolved(config) {
504
711
  projectRoot = config.root;
712
+ isDevMode = config.command === "serve";
713
+
714
+ if (isDevMode && devCacheEnabled) {
715
+ cacheDir = path.join(projectRoot, ".macroforge", "cache");
716
+ macroforgeVersion = getMacroforgeVersion();
717
+ cacheManifest = loadCacheManifest();
718
+
719
+ if (cacheManifest) {
720
+ const entryCount = Object.keys(cacheManifest.entries).length;
721
+ console.log(
722
+ `[@macroforge/vite-plugin] Dev cache loaded: ${entryCount} entries`,
723
+ );
724
+ }
725
+ }
726
+ },
727
+
728
+ /**
729
+ * Pre-scan the project to build a type registry for compile-time type awareness.
730
+ * The registry is passed to every expandSync call so macros can introspect
731
+ * any type in the project (Zig-style type awareness).
732
+ */
733
+ buildStart() {
734
+ if (!rustTransformer || !rustTransformer.scanProjectSync) {
735
+ return;
736
+ }
737
+
738
+ try {
739
+ const scanStart = performance.now();
740
+ const scanResult = rustTransformer.scanProjectSync(projectRoot, {
741
+ exportedOnly: false,
742
+ });
743
+ const scanTime = (performance.now() - scanStart).toFixed(0);
744
+
745
+ typeRegistryJson = scanResult.registryJson;
746
+
747
+ console.log(
748
+ `[@macroforge/vite-plugin] Type scan: ${scanResult.typesFound} types from ${scanResult.filesScanned} files (${scanTime}ms)`,
749
+ );
750
+
751
+ for (const diag of scanResult.diagnostics) {
752
+ if (diag.level === "error") {
753
+ console.error(
754
+ `[@macroforge/vite-plugin] Scan error: ${diag.message}`,
755
+ );
756
+ }
757
+ }
758
+ } catch (error) {
759
+ console.warn(
760
+ `[@macroforge/vite-plugin] Type scan failed, macros will run without type awareness:`,
761
+ error.message || error,
762
+ );
763
+ typeRegistryJson = undefined;
764
+ }
505
765
  },
506
766
 
507
767
  /**
@@ -509,10 +769,6 @@ export async function macroforge() {
509
769
  * @param {string} id
510
770
  */
511
771
  async transform(code, id) {
512
- // Ensure require() is available for native module loading
513
- // Use the project's CWD-based require for resolving external macro packages
514
- const projectRequire = await ensureRequire();
515
-
516
772
  // Only transform TypeScript files
517
773
  if (!id.endsWith(".ts") && !id.endsWith(".tsx")) {
518
774
  return null;
@@ -533,7 +789,53 @@ export async function macroforge() {
533
789
  return null;
534
790
  }
535
791
 
792
+ // Quick check: files without @derive can't have macros — skip entirely
793
+ if (!code.includes("@derive")) {
794
+ return null;
795
+ }
796
+
536
797
  try {
798
+ // --- Dev cache read ---
799
+ if (isDevMode && devCacheEnabled && cacheManifest) {
800
+ const cached = readCacheEntry(id, code);
801
+ if (cached) {
802
+ let cachedCode = cached.code;
803
+
804
+ // Apply same post-processing as the normal path
805
+ cachedCode = cachedCode.replace(
806
+ /\/\*\*\s*import\s+macro[\s\S]*?\*\/\s*/gi,
807
+ "",
808
+ );
809
+ if (id.endsWith(".svelte.ts") || id.endsWith(".svelte.js")) {
810
+ cachedCode = cachedCode.replace(
811
+ /\/\*\*\s*@derive\b[^*]*\*\//g,
812
+ "",
813
+ );
814
+ }
815
+
816
+ // Generate type definitions from cached expanded code
817
+ if (generateTypes) {
818
+ const emitted = emitDeclarationsFromCode(
819
+ cachedCode,
820
+ id,
821
+ projectRoot,
822
+ );
823
+ if (emitted) {
824
+ writeTypeDefinitions(id, emitted);
825
+ }
826
+ }
827
+
828
+ return {
829
+ code: cachedCode,
830
+ map: null,
831
+ };
832
+ }
833
+ }
834
+
835
+ // Ensure require() is available for native module loading
836
+ // Use the project's CWD-based require for resolving external macro packages
837
+ const projectRequire = await ensureRequire();
838
+
537
839
  // Collect external decorator modules from macro imports
538
840
  // Use projectRequire to resolve packages from the project's CWD, not the plugin's location
539
841
  const externalDecoratorModules = collectExternalDecoratorModules(
@@ -546,6 +848,7 @@ export async function macroforge() {
546
848
  keepDecorators: macroConfig.keepDecorators,
547
849
  externalDecoratorModules,
548
850
  configPath: macroConfig.configPath,
851
+ typeRegistryJson,
549
852
  });
550
853
 
551
854
  // Report diagnostics from macro expansion
@@ -563,6 +866,14 @@ export async function macroforge() {
563
866
  }
564
867
 
565
868
  if (result && result.code) {
869
+ // Check if macros were actually expanded
870
+ const hasMacros = result.sourceMapping?.generatedRegions?.length > 0;
871
+
872
+ // --- Dev cache write (self-populating) ---
873
+ if (isDevMode && devCacheEnabled) {
874
+ writeCacheEntry(id, code, result.code, hasMacros);
875
+ }
876
+
566
877
  // Remove macro-only imports so SSR output doesn't load native bindings
567
878
  result.code = result.code.replace(
568
879
  /\/\*\*\s*import\s+macro[\s\S]*?\*\/\s*/gi,
@@ -612,6 +923,17 @@ export async function macroforge() {
612
923
 
613
924
  return null;
614
925
  },
926
+
927
+ /**
928
+ * Flush the cache manifest on server close.
929
+ */
930
+ buildEnd() {
931
+ if (manifestFlushTimer) {
932
+ clearTimeout(manifestFlushTimer);
933
+ manifestFlushTimer = undefined;
934
+ }
935
+ flushCacheManifest();
936
+ },
615
937
  };
616
938
 
617
939
  return plugin;