mage-obsidian 1.1.2 → 2.0.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 (111) hide show
  1. package/.github/workflows/ci.yml +38 -0
  2. package/.github/workflows/publish.yml +26 -25
  3. package/.oxlintrc.json +19 -0
  4. package/.prettierignore +7 -0
  5. package/.prettierrc.json +7 -0
  6. package/LICENSE +1 -1
  7. package/README.md +1 -0
  8. package/eslint.config.js +24 -0
  9. package/package.json +39 -13
  10. package/pnpm-workspace.yaml +4 -0
  11. package/src/cli/buildThemes.ts +263 -0
  12. package/src/config/default.ts +31 -0
  13. package/src/config/eslint.js +60 -0
  14. package/src/core/configResolver.ts +141 -0
  15. package/src/core/contractValidator.ts +56 -0
  16. package/src/core/cssResolver.ts +153 -0
  17. package/src/{service/generateInterceptors.js → core/generateInterceptors.ts} +79 -46
  18. package/src/core/generateJsconfig.ts +168 -0
  19. package/src/{service/moduleResolver.js → core/moduleResolver.ts} +79 -46
  20. package/src/core/preCompileFiles.ts +105 -0
  21. package/src/core/preCompileMagentoFiles.ts +34 -0
  22. package/src/core/themeResolverSync.ts +66 -0
  23. package/src/runtime/i18nCore.ts +113 -0
  24. package/src/runtime/interceptorManager.ts +186 -0
  25. package/src/runtime/islands.ts +84 -0
  26. package/src/runtime/sectionStoreCore.ts +259 -0
  27. package/src/utils/{findComponents.js → findComponents.ts} +29 -16
  28. package/src/utils/runWithConcurrency.ts +35 -0
  29. package/src/vite/defaultNodeResolver.ts +17 -0
  30. package/src/{service/inheritAssetsModuleResolver.js → vite/inheritAssetsModuleResolver.ts} +15 -11
  31. package/src/{service/inheritModuleResolver.js → vite/inheritModuleResolver.ts} +6 -10
  32. package/src/{service/interceptorsPlugin.js → vite/interceptorsPlugin.ts} +25 -19
  33. package/src/vite/magentoHrmRewrite.ts +44 -0
  34. package/src/vite/sharedPlugins.ts +54 -0
  35. package/src/vite/themeSourceWatcher.ts +96 -0
  36. package/src/vite/unresolvedModuleGuard.ts +87 -0
  37. package/tsconfig.json +34 -0
  38. package/vitest.config.js +18 -0
  39. package/jest.config.js +0 -5
  40. package/src/__tests__/__mocks__/configResolver.js +0 -35
  41. package/src/__tests__/fixtures/interceptors/emptyTarget.js +0 -2
  42. package/src/__tests__/fixtures/interceptors/pluginForNonFunction.js +0 -7
  43. package/src/__tests__/fixtures/interceptors/pluginModule.js +0 -13
  44. package/src/__tests__/fixtures/interceptors/targetModule.js +0 -12
  45. package/src/__tests__/fixtures/interceptors/targetNonFunction.js +0 -7
  46. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/components/ComponentA.vue +0 -0
  47. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/components/ComponentB.vue +0 -0
  48. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/css/module.extend.css +0 -0
  49. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/js/main.js +0 -0
  50. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/main.js +0 -0
  51. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/module.config.js +0 -9
  52. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/components/ComponentA.vue +0 -0
  53. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/components/ComponentB.vue +0 -0
  54. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/css/module.extend.css +0 -2
  55. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/js/main.js +0 -0
  56. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/module.config.js +0 -7
  57. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/components/ComponentA.vue +0 -0
  58. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/components/ComponentB.vue +0 -0
  59. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/css/module.extend.css +0 -2
  60. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/js/main.js +0 -0
  61. package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/module.config.js +0 -7
  62. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/components/ComponentA.vue +0 -0
  63. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/components/ComponentB.vue +0 -0
  64. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/css/module.extend.css +0 -0
  65. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/js/main.js +0 -0
  66. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/module.config.js +0 -2
  67. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/components/ComponentA.vue +0 -0
  68. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/components/ComponentB.vue +0 -0
  69. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/css/module.extend.css +0 -0
  70. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/js/main.js +0 -0
  71. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/module.config.js +0 -2
  72. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/components/ComponentA.vue +0 -0
  73. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/components/ComponentB.vue +0 -0
  74. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/css/module.extend.css +0 -0
  75. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/js/main.js +0 -0
  76. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/module.config.js +0 -2
  77. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/components/ComponentA.vue +0 -0
  78. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/components/ComponentB.vue +0 -0
  79. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/css/theme.source.css +0 -6
  80. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/js/main.js +0 -0
  81. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/theme.config.js +0 -5
  82. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-b/web/css/theme.source.css +0 -6
  83. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-b/web/theme.config.js +0 -5
  84. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/components/ComponentA.vue +0 -0
  85. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/components/ComponentB.vue +0 -0
  86. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/css/module.extend.css +0 -0
  87. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/js/main.js +0 -0
  88. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/module.config.js +0 -2
  89. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/web/css/theme.source.css +0 -6
  90. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/web/theme.config.js +0 -2
  91. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-d/web/css/theme.source.css +0 -6
  92. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-d/web/theme.config.js +0 -3
  93. package/src/__tests__/magento_scenarios/app/etc/mage_obsidian_frontend_modules_a.json +0 -53
  94. package/src/__tests__/magento_scenarios/app/etc/mage_obsidian_frontend_modules_b.json +0 -50
  95. package/src/__tests__/service/generateInterceptors.test.js +0 -196
  96. package/src/__tests__/service/inheritModuleResolver.test.js +0 -61
  97. package/src/__tests__/service/interceptorManager_magento_behavior.test.js +0 -137
  98. package/src/__tests__/service/moduleResolver.test.js +0 -194
  99. package/src/__tests__/service/themeResolverSync.test.js +0 -97
  100. package/src/__tests__/vite-plugins/interceptorsPlugin.test.js +0 -121
  101. package/src/config/default.js +0 -27
  102. package/src/scripts/buildThemes.js +0 -132
  103. package/src/service/configResolver.js +0 -63
  104. package/src/service/cssResolver.js +0 -73
  105. package/src/service/defaultNodeResolver.js +0 -16
  106. package/src/service/interceptorManager.js +0 -137
  107. package/src/service/magentoHrmRewrite.js +0 -43
  108. package/src/service/preCompileFiles.js +0 -37
  109. package/src/service/preCompileMagentoFiles.js +0 -16
  110. package/src/service/setupGlobals.js +0 -19
  111. package/src/service/themeResolverSync.js +0 -67
@@ -2,8 +2,9 @@ import fs from "fs";
2
2
  import path from "path";
3
3
 
4
4
  /**
5
- * Recursively searches for `.vue` and `.js` files in the `components` directory
6
- * of a given module and organizes them into an object.
5
+ * Recursively searches a module's source folders for component files — matched by
6
+ * the extensions each folder declares (`directory.ext`) — and organizes them into
7
+ * an object.
7
8
  *
8
9
  * @param {string} moduleName - The name of the module to associate the files with.
9
10
  * @param moduleDir
@@ -14,12 +15,9 @@ import path from "path";
14
15
  * - The values are the full relative paths to the files within the `components` folder.
15
16
  * @throws {Error} - Throws an error if duplicate file names (with different extensions) are found.
16
17
  */
17
- async function getFilesFromFolders(
18
- moduleName,
19
- moduleDir,
20
- directories
21
- ) {
18
+ async function getFilesFromFolders(moduleName, moduleDir, directories) {
22
19
  const componentsDir = path.resolve(moduleDir);
20
+ // oxlint-disable-next-line only-used-in-recursion
23
21
  const getFilesFromFolderWithExt = async (dir, folderToSearch, extArr, baseDir = "") => {
24
22
  // console.log(moduleName, dir, folderToSearch, extArr, baseDir = "");
25
23
  if (!fs.existsSync(dir)) return [];
@@ -31,13 +29,21 @@ async function getFilesFromFolders(
31
29
  const fullPath = path.join(dir, entry.name);
32
30
  const relativePath = path.join(baseDir, entry.name);
33
31
  if (entry.isDirectory()) {
34
- return await getFilesFromFolderWithExt(fullPath, folderToSearch, extArr, relativePath);
35
- } else if (entry.isFile() && (entry.name.endsWith(".vue") || entry.name.endsWith(".js"))) {
32
+ return await getFilesFromFolderWithExt(
33
+ fullPath,
34
+ folderToSearch,
35
+ extArr,
36
+ relativePath,
37
+ );
38
+ } else if (
39
+ entry.isFile() &&
40
+ extArr.some((ext) => entry.name.endsWith(`.${ext}`)) &&
41
+ // Skip co-located unit tests/specs so test-only deps
42
+ // (e.g. @vue/test-utils) never leak into the build.
43
+ !/\.(test|spec)\.[jt]sx?$/.test(entry.name)
44
+ ) {
36
45
  const fileName = path.parse(entry.name).name;
37
- let key =
38
- baseDir === ""
39
- ? fileName
40
- : `${baseDir}/${fileName}`;
46
+ let key = baseDir === "" ? fileName : `${baseDir}/${fileName}`;
41
47
  key = `${moduleName}/${folderToSearch}/${key}`;
42
48
  const filePath = path.join(dir, entry.name);
43
49
  if (keyRegistered.includes(key)) {
@@ -47,7 +53,7 @@ async function getFilesFromFolders(
47
53
  return { [key]: filePath };
48
54
  }
49
55
  return null;
50
- })
56
+ }),
51
57
  );
52
58
  return files.filter(Boolean).flat();
53
59
  };
@@ -56,14 +62,21 @@ async function getFilesFromFolders(
56
62
  let result = [];
57
63
  for (const directory of directories) {
58
64
  const folderPath = path.resolve(componentsDir, directory.src);
59
- const filesArray = await getFilesFromFolderWithExt(folderPath, directory.src, directory.ext);
65
+ const filesArray = await getFilesFromFolderWithExt(
66
+ folderPath,
67
+ directory.src,
68
+ directory.ext,
69
+ );
60
70
  result.push(...filesArray);
61
71
  }
62
72
  result = result.reduce((acc, item) => Object.assign(acc, item), {});
63
73
 
64
74
  return result;
65
75
  } catch (err) {
66
- throw new Error(`Error while processing JavaScript or Vue files in the components folder of module "${moduleName}": ${err.message}`);
76
+ throw new Error(
77
+ `Error while processing JavaScript or Vue files in the components folder of module "${moduleName}": ${err.message}`,
78
+ { cause: err },
79
+ );
67
80
  }
68
81
  }
69
82
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Run async workers over a list of items with a bounded number running at once.
3
+ *
4
+ * Results are returned in the same order as `items` regardless of completion
5
+ * order. The first rejection aborts the run (no new workers are started) and is
6
+ * propagated, mirroring Promise.all semantics. `limit` defaults to the number of
7
+ * items.
8
+ */
9
+ export default async function runWithConcurrency<T, R>(
10
+ items: T[],
11
+ worker: (item: T, index: number) => Promise<R>,
12
+ limit: number = items.length,
13
+ ): Promise<R[]> {
14
+ if (!Array.isArray(items)) {
15
+ throw new TypeError("runWithConcurrency: items must be an array.");
16
+ }
17
+ if (typeof worker !== "function") {
18
+ throw new TypeError("runWithConcurrency: worker must be a function.");
19
+ }
20
+
21
+ const effectiveLimit = Math.max(1, Math.min(limit, items.length || 1));
22
+ const results = Array.from({ length: items.length }) as R[];
23
+ let nextIndex = 0;
24
+
25
+ async function runner() {
26
+ while (nextIndex < items.length) {
27
+ const current = nextIndex++;
28
+ results[current] = await worker(items[current], current);
29
+ }
30
+ }
31
+
32
+ const runners = Array.from({ length: Math.min(effectiveLimit, items.length) }, runner);
33
+ await Promise.all(runners);
34
+ return results;
35
+ }
@@ -0,0 +1,17 @@
1
+ export default {
2
+ name: "default-node-resolver",
3
+ resolveId: {
4
+ order: "post",
5
+ handler(id) {
6
+ try {
7
+ const resolvedPath = import.meta.resolve(id);
8
+ if (resolvedPath) {
9
+ return resolvedPath.replace("file://", "");
10
+ }
11
+ } catch {
12
+ // Not resolvable as a node package; let later resolvers handle it.
13
+ }
14
+ return null;
15
+ },
16
+ },
17
+ };
@@ -1,12 +1,11 @@
1
1
  import path from "path";
2
- import configResolver from "./configResolver.js";
2
+ import configResolver from "../core/configResolver.ts";
3
3
  import fs from "fs";
4
4
 
5
5
  export default function customAssetsResolverPlugin() {
6
6
  const CURRENT_THEME = process.env.CURRENT_THEME;
7
- const THEME_ASSETS_PATH = 'web';
8
- const MODULE_ASSETS_PATH = 'view/frontend/web';
9
-
7
+ const THEME_ASSETS_PATH = "web";
8
+ const MODULE_ASSETS_PATH = "view/frontend/web";
10
9
 
11
10
  function tryResolveAssetPathByTheme(themeName, filePath) {
12
11
  const themeDefinition = configResolver.getThemeDefinition(themeName);
@@ -36,16 +35,21 @@ export default function customAssetsResolverPlugin() {
36
35
  return null;
37
36
  }
38
37
 
39
-
40
38
  const resolveAssetPath = (moduleName, filePath) => {
41
39
  if (!filePath.startsWith("assets/")) {
42
40
  return null;
43
41
  }
44
- let assetSrc = null;
42
+ let assetSrc;
45
43
  if (moduleName === "Theme") {
46
- assetSrc = tryResolveAssetPathByTheme(CURRENT_THEME, path.join(THEME_ASSETS_PATH, filePath));
44
+ assetSrc = tryResolveAssetPathByTheme(
45
+ CURRENT_THEME,
46
+ path.join(THEME_ASSETS_PATH, filePath),
47
+ );
47
48
  } else {
48
- assetSrc = tryResolveAssetPathByTheme(CURRENT_THEME, path.join(moduleName, THEME_ASSETS_PATH, filePath));
49
+ assetSrc = tryResolveAssetPathByTheme(
50
+ CURRENT_THEME,
51
+ path.join(moduleName, THEME_ASSETS_PATH, filePath),
52
+ );
49
53
  }
50
54
  if (!assetSrc) {
51
55
  assetSrc = tryResolveAssetPathByModule(moduleName, filePath);
@@ -57,7 +61,7 @@ export default function customAssetsResolverPlugin() {
57
61
  return {
58
62
  name: "inherit-assets-resolver",
59
63
  resolveId: {
60
- order: 'pre',
64
+ order: "pre",
61
65
  handler(id) {
62
66
  if (!id) {
63
67
  return;
@@ -72,7 +76,7 @@ export default function customAssetsResolverPlugin() {
72
76
  return;
73
77
  }
74
78
  return assetSrc;
75
- }
76
- }
79
+ },
80
+ },
77
81
  };
78
82
  }
@@ -1,6 +1,6 @@
1
- import moduleResolver from "./moduleResolver.js";
1
+ import moduleResolver from "../core/moduleResolver.ts";
2
2
  import path from "path";
3
- import configResolver from "./configResolver.js";
3
+ import configResolver from "../core/configResolver.ts";
4
4
 
5
5
  export default function customResolverPlugin() {
6
6
  const allComponents = moduleResolver.getAllJsVueFilesWithInheritanceCached();
@@ -10,22 +10,18 @@ export default function customResolverPlugin() {
10
10
  validComponentExtensions.some((ext) => filePath.endsWith(ext));
11
11
  const hasExtension = (filePath) => !!path.extname(filePath);
12
12
 
13
-
14
13
  const resolveComponentPath = (moduleName, filePath) => {
15
14
  if (!filePath.startsWith("components/") && !filePath.startsWith("js/")) {
16
15
  filePath = "components/" + filePath;
17
16
  }
18
- const fileName = path.join(
19
- path.dirname(filePath),
20
- path.parse(filePath).name
21
- );
17
+ const fileName = path.join(path.dirname(filePath), path.parse(filePath).name);
22
18
  return allComponents[`${moduleName}/${fileName}`];
23
19
  };
24
20
 
25
21
  return {
26
22
  name: "inherit-resolver",
27
23
  resolveId: {
28
- order: 'pre',
24
+ order: "pre",
29
25
  handler(id) {
30
26
  if (!id) {
31
27
  return;
@@ -44,7 +40,7 @@ export default function customResolverPlugin() {
44
40
  }
45
41
 
46
42
  return componentSrc;
47
- }
48
- }
43
+ },
44
+ },
49
45
  };
50
46
  }
@@ -1,22 +1,25 @@
1
- import generateInterceptorsService from './generateInterceptors.js';
1
+ import generateInterceptorsService from "../core/generateInterceptors.ts";
2
2
 
3
- export default function interceptorsPlugin(options = {}) {
3
+ export default function interceptorsPlugin(options: { themeName?: string } = {}) {
4
4
  const { themeName } = options;
5
- let interceptorsMap = new Map(); // path -> interceptorData
5
+ const interceptorsMap = new Map(); // path -> interceptorData
6
6
 
7
7
  return {
8
- name: 'mage-obsidian:interceptors',
9
- enforce: 'pre',
8
+ name: "mage-obsidian:interceptors",
9
+ enforce: "pre",
10
10
 
11
11
  async buildStart() {
12
12
  if (!themeName) {
13
- console.warn('[mage-obsidian:interceptors] themeName option is missing. Interceptors will not be generated.');
13
+ console.warn(
14
+ "[mage-obsidian:interceptors] themeName option is missing. Interceptors will not be generated.",
15
+ );
14
16
  return;
15
17
  }
16
-
18
+
17
19
  try {
18
- const interceptors = await generateInterceptorsService.generateInterceptors(themeName);
19
-
20
+ const interceptors =
21
+ await generateInterceptorsService.generateInterceptors(themeName);
22
+
20
23
  // Create a map for fast lookup by file path
21
24
  for (const key in interceptors) {
22
25
  const data = interceptors[key];
@@ -25,25 +28,28 @@ export default function interceptorsPlugin(options = {}) {
25
28
  }
26
29
  }
27
30
  } catch (error) {
28
- console.error('[mage-obsidian:interceptors] Failed to generate interceptors:', error);
31
+ console.error(
32
+ "[mage-obsidian:interceptors] Failed to generate interceptors:",
33
+ error,
34
+ );
29
35
  }
30
36
  },
31
37
 
32
38
  async resolveId(source, importer) {
33
39
  // Skip if we haven't loaded interceptors or if it's a virtual module
34
- if (interceptorsMap.size === 0 || source.startsWith('\0')) return null;
40
+ if (interceptorsMap.size === 0 || source.startsWith("\0")) return null;
35
41
 
36
42
  // Try to resolve the import to a full path
37
43
  const resolution = await this.resolve(source, importer, { skipSelf: true });
38
-
44
+
39
45
  if (!resolution || !resolution.id) return null;
40
-
46
+
41
47
  // Clean up the ID (remove query params)
42
- const resolvedId = resolution.id.split('?')[0];
48
+ const resolvedId = resolution.id.split("?")[0];
43
49
 
44
50
  if (interceptorsMap.has(resolvedId)) {
45
51
  const virtualId = `\0interceptor:${resolvedId}`;
46
-
52
+
47
53
  // Check if we are inside the interceptor trying to import the original
48
54
  if (importer === virtualId) {
49
55
  return null; // Allow original import
@@ -56,15 +62,15 @@ export default function interceptorsPlugin(options = {}) {
56
62
  },
57
63
 
58
64
  load(id) {
59
- if (id.startsWith('\0interceptor:')) {
60
- const originalPath = id.slice('\0interceptor:'.length);
65
+ if (id.startsWith("\0interceptor:")) {
66
+ const originalPath = id.slice("\0interceptor:".length);
61
67
  const data = interceptorsMap.get(originalPath);
62
-
68
+
63
69
  if (data) {
64
70
  return data.source;
65
71
  }
66
72
  }
67
73
  return null;
68
- }
74
+ },
69
75
  };
70
76
  }
@@ -0,0 +1,44 @@
1
+ import moduleResolver from "../core/moduleResolver.ts";
2
+
3
+ export default () => {
4
+ const components = moduleResolver.getAllJsVueFilesWithInheritanceCached();
5
+ return {
6
+ name: "handle-magento-routes-middleware",
7
+ configureServer(server) {
8
+ server.middlewares.use((req, res, next) => {
9
+ const url = req.url;
10
+
11
+ const matchedKey = Object.keys(components).find((key) => url.includes(key));
12
+
13
+ if (matchedKey) {
14
+ const filePath = components[matchedKey];
15
+ let suffix;
16
+ if (matchedKey === "lib/vue") {
17
+ suffix = "/";
18
+ } else if (matchedKey.startsWith("lib/")) {
19
+ suffix = "/@fs";
20
+ const fileExtension = filePath.split(".").pop();
21
+ let mimeType = "application/node";
22
+ if (
23
+ fileExtension === "cjs" ||
24
+ fileExtension === "mjs" ||
25
+ fileExtension === "js"
26
+ )
27
+ mimeType = "application/javascript";
28
+ if (fileExtension === "css") mimeType = "text/css";
29
+ if (fileExtension === "json") mimeType = "application/json";
30
+ if (fileExtension === "html") mimeType = "text/html";
31
+
32
+ res.setHeader("Content-Type", mimeType);
33
+ } else {
34
+ suffix = "/@fs";
35
+ }
36
+ req.url = `${suffix}${filePath}`;
37
+ next();
38
+ } else {
39
+ next();
40
+ }
41
+ });
42
+ },
43
+ };
44
+ };
@@ -0,0 +1,54 @@
1
+ import inheritModuleResolver from "./inheritModuleResolver.ts";
2
+ import inheritAssetsModuleResolver from "./inheritAssetsModuleResolver.ts";
3
+ import defaultNodeResolve from "./defaultNodeResolver.ts";
4
+ import unresolvedModuleGuard from "./unresolvedModuleGuard.ts";
5
+ import preCompileMagentoFiles from "../core/preCompileMagentoFiles.ts";
6
+ import configResolver from "../core/configResolver.ts";
7
+
8
+ /**
9
+ * The framework's module-resolution plugin chain: `Vendor_Module::` component
10
+ * and asset resolution plus the node-package fallback. Resolution order is
11
+ * governed by each plugin's resolveId `order` ('pre'/'post'), not by array
12
+ * position, so this group can sit anywhere in a consumer's plugin list.
13
+ *
14
+ * Shared so the dev/build config, component tests, auto-import and the
15
+ * playground all resolve imports through the exact same chain instead of each
16
+ * reimplementing it and drifting.
17
+ */
18
+ export function getResolverPlugins() {
19
+ return [
20
+ inheritModuleResolver(),
21
+ inheritAssetsModuleResolver(),
22
+ defaultNodeResolve,
23
+ unresolvedModuleGuard(),
24
+ ];
25
+ }
26
+
27
+ /**
28
+ * Ensure a theme's precompiled entries exist on disk before a build/test/story
29
+ * run. Idempotent per (theme, contract hash). Any consumer that resolves
30
+ * `Vendor_Module::` imports MUST call this first: without it the resolver map
31
+ * is empty and those imports resolve to undefined with no error.
32
+ */
33
+ export function ensurePrecompiled(themeName) {
34
+ return preCompileMagentoFiles(themeName);
35
+ }
36
+
37
+ /**
38
+ * Build Vite's `server.fs.allow` list from the contract. Modules and themes can
39
+ * live outside the Magento root — e.g. symlinked from a monorepo during dev —
40
+ * in which case their real `src` roots are outside the default allow-list and
41
+ * Vite answers 403 for any file under them. A single 403 on a bootstrap module
42
+ * (such as the i18n runtime) aborts the whole ESM graph, so the page renders
43
+ * unstyled with no components. Deriving the list from each module/theme `src`
44
+ * makes the dev server serve sources wherever they physically live.
45
+ */
46
+ export function getFsAllowList(rootDir) {
47
+ const srcRoots = [
48
+ ...configResolver.getModulesConfigArray(),
49
+ ...configResolver.getThemesConfigArray(),
50
+ ]
51
+ .map(([, definition]) => definition?.src)
52
+ .filter(Boolean);
53
+ return [...new Set([rootDir, ...srcRoots])];
54
+ }
@@ -0,0 +1,96 @@
1
+ import path from "node:path";
2
+ import configResolver from "../core/configResolver.ts";
3
+ import moduleResolver from "../core/moduleResolver.ts";
4
+ import { precompileJs, precompileJsconfig, precompileTsconfig } from "../core/preCompileFiles.ts";
5
+ import { MODULE_WEB_PATH } from "../config/default.ts";
6
+
7
+ const SOURCE_EXT = /\.(vue|ts|js)$/;
8
+ // Build output and engine scratch live under the watched trees but are not
9
+ // authored sources — reacting to them would loop (we write them ourselves).
10
+ const IGNORED = /[/\\](generated|\.precompiled|node_modules)[/\\]/;
11
+ const DEBOUNCE_MS = 150;
12
+
13
+ // The source roots whose structure feeds the inheritance map: every opted-in
14
+ // module's web dir plus the theme and its parent chain. Adding/removing a
15
+ // `.vue`/`.ts`/`.js` under these changes what `Vendor_Module::` can resolve.
16
+ function collectWatchDirs(themeName) {
17
+ const dirs = new Set();
18
+ for (const [, definition] of configResolver.getModulesConfigArray()) {
19
+ if (definition?.src) dirs.add(path.resolve(definition.src, MODULE_WEB_PATH));
20
+ }
21
+ const themes = configResolver.getMagentoConfig().themes;
22
+ const seen = new Set();
23
+ let name = themeName;
24
+ while (name && themes[name] && !seen.has(name)) {
25
+ seen.add(name);
26
+ if (themes[name].src) dirs.add(themes[name].src);
27
+ name = themes[name].parent;
28
+ }
29
+ return [...dirs];
30
+ }
31
+
32
+ /**
33
+ * Dev-only plugin that keeps `Vendor_Module::` resolution live. Precompile runs
34
+ * once at server start and is cached by contract hash; adding or removing a
35
+ * component/js source changes neither, so without this the new file is invisible
36
+ * to both the runtime resolver and the editor jsconfig until a restart. On a
37
+ * structural change we drop the theme cache, rewrite the persisted inheritance
38
+ * map (runtime) and regenerate the theme jsconfig (editor). Writes are
39
+ * idempotent, so the burst of events when the watch dirs are first scanned
40
+ * collapses to a single no-op.
41
+ */
42
+ export default function themeSourceWatcher(themeName) {
43
+ return {
44
+ name: "mage-obsidian:theme-source-watcher",
45
+ apply: "serve",
46
+ configureServer(server) {
47
+ for (const dir of collectWatchDirs(themeName)) {
48
+ server.watcher.add(dir);
49
+ }
50
+
51
+ let timer = null;
52
+ let running = false;
53
+ let queued = false;
54
+
55
+ const regenerate = async () => {
56
+ if (running) {
57
+ queued = true;
58
+ return;
59
+ }
60
+ running = true;
61
+ try {
62
+ moduleResolver.invalidateTheme(themeName);
63
+ await precompileJs(themeName);
64
+ await precompileJsconfig(themeName);
65
+ await precompileTsconfig(themeName);
66
+ server.config.logger.info(
67
+ `[mage-obsidian] sources changed — refreshed ${themeName} import resolution`,
68
+ );
69
+ } catch (error) {
70
+ server.config.logger.error(
71
+ `[mage-obsidian] failed to refresh import resolution: ${error?.message ?? error}`,
72
+ );
73
+ } finally {
74
+ running = false;
75
+ if (queued) {
76
+ queued = false;
77
+ schedule();
78
+ }
79
+ }
80
+ };
81
+
82
+ const schedule = () => {
83
+ if (timer) clearTimeout(timer);
84
+ timer = setTimeout(regenerate, DEBOUNCE_MS);
85
+ };
86
+
87
+ const onChange = (file) => {
88
+ if (!SOURCE_EXT.test(file) || IGNORED.test(file)) return;
89
+ schedule();
90
+ };
91
+
92
+ server.watcher.on("add", onChange);
93
+ server.watcher.on("unlink", onChange);
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,87 @@
1
+ import moduleResolver from "../core/moduleResolver.ts";
2
+
3
+ // The part before `::` must look like a Magento module (Vendor_Module) or the
4
+ // special `Theme` namespace. This keeps the guard from firing on unrelated ids
5
+ // (virtual modules, already-resolved absolute paths) that happen to contain `::`.
6
+ const NAMESPACE_PATTERN = /^(Theme|[A-Z][A-Za-z0-9_]*)$/;
7
+
8
+ function distance(a: string, b: string): number {
9
+ const rows = a.length + 1;
10
+ const cols = b.length + 1;
11
+ const dp = Array.from({ length: rows }, () => Array.from({ length: cols }, () => 0));
12
+ for (let i = 0; i < rows; i++) dp[i][0] = i;
13
+ for (let j = 0; j < cols; j++) dp[0][j] = j;
14
+ for (let i = 1; i < rows; i++) {
15
+ for (let j = 1; j < cols; j++) {
16
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
17
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
18
+ }
19
+ }
20
+ return dp[a.length][b.length];
21
+ }
22
+
23
+ function suggest(namespace: string, target: string): string[] {
24
+ let keys: string[];
25
+ try {
26
+ keys = Object.keys(moduleResolver.getAllJsVueFilesWithInheritanceCached());
27
+ } catch {
28
+ // No precompiled cache yet — nothing to suggest from.
29
+ return [];
30
+ }
31
+ const prefix = `${namespace}/`;
32
+ return keys
33
+ .filter((key) => key.startsWith(prefix))
34
+ .map((key) => `${namespace}::${key.slice(prefix.length)}`)
35
+ .map((candidate) => [candidate, distance(candidate, target)] as const)
36
+ .sort((a, b) => a[1] - b[1])
37
+ .slice(0, 3)
38
+ .map(([candidate]) => candidate);
39
+ }
40
+
41
+ function buildMessage(id: string, namespace: string, rest: string, importer?: string): string {
42
+ const from = importer ? `\n imported by: ${importer}` : "";
43
+
44
+ let hint: string;
45
+ if (rest.startsWith("assets/")) {
46
+ hint =
47
+ `\n Asset not found. Expected a theme override at` +
48
+ ` <theme>/${namespace}/web/${rest} or the module at` +
49
+ ` <module>/view/frontend/web/${rest}.`;
50
+ } else {
51
+ const suggestions = suggest(namespace, id);
52
+ hint = suggestions.length
53
+ ? `\n Did you mean:\n${suggestions.map((s) => ` - ${s}`).join("\n")}`
54
+ : `\n Nothing is registered under "${namespace}". Is the module/theme enabled` +
55
+ ` and compatible, and the contract regenerated` +
56
+ ` (bin/magento mage-obsidian:frontend:config --generate)?`;
57
+ }
58
+
59
+ return `[mage-obsidian] Unresolved import "${id}".${from}${hint}`;
60
+ }
61
+
62
+ /**
63
+ * Fail-loud guard for the framework's `Vendor_Module::` import notation. It runs
64
+ * last in the resolver chain (`order: "post"`), so a `::` specifier only reaches
65
+ * it when none of the earlier resolvers (component, asset, node) could resolve
66
+ * it — i.e. a typo or a missing file. Without this, Rollup externalizes the
67
+ * unknown specifier silently and the import breaks at runtime with no clear
68
+ * cause; here it becomes a build/dev error naming the specifier, the importer,
69
+ * and the closest valid alternatives.
70
+ */
71
+ export default function unresolvedModuleGuard() {
72
+ return {
73
+ name: "unresolved-module-guard",
74
+ resolveId: {
75
+ order: "post",
76
+ handler(id: string, importer?: string) {
77
+ if (!id || id[0] === "\0") return;
78
+ const separator = id.indexOf("::");
79
+ if (separator === -1) return;
80
+ const namespace = id.slice(0, separator);
81
+ if (!NAMESPACE_PATTERN.test(namespace)) return;
82
+
83
+ throw new Error(buildMessage(id, namespace, id.slice(separator + 2), importer));
84
+ },
85
+ },
86
+ };
87
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "types": ["node"],
8
+
9
+ // No build in dev: Vite and Node (>=22.18 type-stripping) consume the
10
+ // .ts sources directly. tsc is type-check only. Importing `.ts`
11
+ // specifiers is therefore allowed, and only erasable TS syntax is
12
+ // permitted so type-stripping never fails.
13
+ "noEmit": true,
14
+ "allowImportingTsExtensions": true,
15
+ "erasableSyntaxOnly": true,
16
+ "verbatimModuleSyntax": true,
17
+
18
+ // Author components stay JS; the engine is TS. allowJs keeps the mixed
19
+ // tree resolvable without forcing checkJs on legacy/JS files.
20
+ "allowJs": true,
21
+ "checkJs": false,
22
+
23
+ // Lenient baseline for the JS->TS migration: the tree is now TypeScript
24
+ // and type-strippable, but not yet annotated. strict is the ratchet
25
+ // target, tightened progressively per subdirectory in follow-up work.
26
+ "strict": false,
27
+ "skipLibCheck": true,
28
+ "resolveJsonModule": true,
29
+ "esModuleInterop": true,
30
+ "forceConsistentCasingInFileNames": true
31
+ },
32
+ "include": ["src/**/*.ts", "src/**/*.js", "*.ts", "*.js"],
33
+ "exclude": ["node_modules", ".precompiled", "src/__tests__/magento_scenarios"]
34
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ // Engine unit tests run in Node. Vitest transpiles TS/ESM natively, replacing
4
+ // the `node --experimental-vm-modules` + Jest setup. Data trees under
5
+ // __tests__ are fixtures, not suites.
6
+ export default defineConfig({
7
+ test: {
8
+ environment: "node",
9
+ globals: true,
10
+ include: ["src/__tests__/**/*.test.{js,ts}"],
11
+ exclude: [
12
+ "**/node_modules/**",
13
+ "src/__tests__/magento_scenarios/**",
14
+ "src/__tests__/fixtures/**",
15
+ "src/__tests__/__mocks__/**",
16
+ ],
17
+ },
18
+ });