@macroforge/vite-plugin 0.1.76 → 0.1.78

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