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.
- package/.github/workflows/ci.yml +38 -0
- package/.github/workflows/publish.yml +26 -25
- package/.oxlintrc.json +19 -0
- package/.prettierignore +7 -0
- package/.prettierrc.json +7 -0
- package/LICENSE +1 -1
- package/README.md +1 -0
- package/eslint.config.js +24 -0
- package/package.json +39 -13
- package/pnpm-workspace.yaml +4 -0
- package/src/cli/buildThemes.ts +263 -0
- package/src/config/default.ts +31 -0
- package/src/config/eslint.js +60 -0
- package/src/core/configResolver.ts +141 -0
- package/src/core/contractValidator.ts +56 -0
- package/src/core/cssResolver.ts +153 -0
- package/src/{service/generateInterceptors.js → core/generateInterceptors.ts} +79 -46
- package/src/core/generateJsconfig.ts +168 -0
- package/src/{service/moduleResolver.js → core/moduleResolver.ts} +79 -46
- package/src/core/preCompileFiles.ts +105 -0
- package/src/core/preCompileMagentoFiles.ts +34 -0
- package/src/core/themeResolverSync.ts +66 -0
- package/src/runtime/i18nCore.ts +113 -0
- package/src/runtime/interceptorManager.ts +186 -0
- package/src/runtime/islands.ts +84 -0
- package/src/runtime/sectionStoreCore.ts +259 -0
- package/src/utils/{findComponents.js → findComponents.ts} +29 -16
- package/src/utils/runWithConcurrency.ts +35 -0
- package/src/vite/defaultNodeResolver.ts +17 -0
- package/src/{service/inheritAssetsModuleResolver.js → vite/inheritAssetsModuleResolver.ts} +15 -11
- package/src/{service/inheritModuleResolver.js → vite/inheritModuleResolver.ts} +6 -10
- package/src/{service/interceptorsPlugin.js → vite/interceptorsPlugin.ts} +25 -19
- package/src/vite/magentoHrmRewrite.ts +44 -0
- package/src/vite/sharedPlugins.ts +54 -0
- package/src/vite/themeSourceWatcher.ts +96 -0
- package/src/vite/unresolvedModuleGuard.ts +87 -0
- package/tsconfig.json +34 -0
- package/vitest.config.js +18 -0
- package/jest.config.js +0 -5
- package/src/__tests__/__mocks__/configResolver.js +0 -35
- package/src/__tests__/fixtures/interceptors/emptyTarget.js +0 -2
- package/src/__tests__/fixtures/interceptors/pluginForNonFunction.js +0 -7
- package/src/__tests__/fixtures/interceptors/pluginModule.js +0 -13
- package/src/__tests__/fixtures/interceptors/targetModule.js +0 -12
- package/src/__tests__/fixtures/interceptors/targetNonFunction.js +0 -7
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/components/ComponentA.vue +0 -0
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/components/ComponentB.vue +0 -0
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/css/module.extend.css +0 -0
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/js/main.js +0 -0
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/main.js +0 -0
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/module.config.js +0 -9
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/components/ComponentA.vue +0 -0
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/components/ComponentB.vue +0 -0
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/css/module.extend.css +0 -2
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/js/main.js +0 -0
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/web/module.config.js +0 -7
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/components/ComponentA.vue +0 -0
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/components/ComponentB.vue +0 -0
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/css/module.extend.css +0 -2
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/js/main.js +0 -0
- package/src/__tests__/magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/web/module.config.js +0 -7
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/components/ComponentA.vue +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/components/ComponentB.vue +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/css/module.extend.css +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/js/main.js +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/module.config.js +0 -2
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/components/ComponentA.vue +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/components/ComponentB.vue +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/css/module.extend.css +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/js/main.js +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/module.config.js +0 -2
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/components/ComponentA.vue +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/components/ComponentB.vue +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/css/module.extend.css +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/js/main.js +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/module.config.js +0 -2
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/components/ComponentA.vue +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/components/ComponentB.vue +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/css/theme.source.css +0 -6
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/js/main.js +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/theme.config.js +0 -5
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-b/web/css/theme.source.css +0 -6
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-b/web/theme.config.js +0 -5
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/components/ComponentA.vue +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/components/ComponentB.vue +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/css/module.extend.css +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/js/main.js +0 -0
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/module.config.js +0 -2
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/web/css/theme.source.css +0 -6
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/web/theme.config.js +0 -2
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-d/web/css/theme.source.css +0 -6
- package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-d/web/theme.config.js +0 -3
- package/src/__tests__/magento_scenarios/app/etc/mage_obsidian_frontend_modules_a.json +0 -53
- package/src/__tests__/magento_scenarios/app/etc/mage_obsidian_frontend_modules_b.json +0 -50
- package/src/__tests__/service/generateInterceptors.test.js +0 -196
- package/src/__tests__/service/inheritModuleResolver.test.js +0 -61
- package/src/__tests__/service/interceptorManager_magento_behavior.test.js +0 -137
- package/src/__tests__/service/moduleResolver.test.js +0 -194
- package/src/__tests__/service/themeResolverSync.test.js +0 -97
- package/src/__tests__/vite-plugins/interceptorsPlugin.test.js +0 -121
- package/src/config/default.js +0 -27
- package/src/scripts/buildThemes.js +0 -132
- package/src/service/configResolver.js +0 -63
- package/src/service/cssResolver.js +0 -73
- package/src/service/defaultNodeResolver.js +0 -16
- package/src/service/interceptorManager.js +0 -137
- package/src/service/magentoHrmRewrite.js +0 -43
- package/src/service/preCompileFiles.js +0 -37
- package/src/service/preCompileMagentoFiles.js +0 -16
- package/src/service/setupGlobals.js +0 -19
- 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
|
|
6
|
-
*
|
|
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(
|
|
35
|
-
|
|
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(
|
|
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(
|
|
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 "
|
|
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 =
|
|
8
|
-
const MODULE_ASSETS_PATH =
|
|
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
|
|
42
|
+
let assetSrc;
|
|
45
43
|
if (moduleName === "Theme") {
|
|
46
|
-
assetSrc = tryResolveAssetPathByTheme(
|
|
44
|
+
assetSrc = tryResolveAssetPathByTheme(
|
|
45
|
+
CURRENT_THEME,
|
|
46
|
+
path.join(THEME_ASSETS_PATH, filePath),
|
|
47
|
+
);
|
|
47
48
|
} else {
|
|
48
|
-
assetSrc = tryResolveAssetPathByTheme(
|
|
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:
|
|
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 "
|
|
1
|
+
import moduleResolver from "../core/moduleResolver.ts";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import configResolver from "
|
|
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:
|
|
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
|
|
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
|
-
|
|
5
|
+
const interceptorsMap = new Map(); // path -> interceptorData
|
|
6
6
|
|
|
7
7
|
return {
|
|
8
|
-
name:
|
|
9
|
-
enforce:
|
|
8
|
+
name: "mage-obsidian:interceptors",
|
|
9
|
+
enforce: "pre",
|
|
10
10
|
|
|
11
11
|
async buildStart() {
|
|
12
12
|
if (!themeName) {
|
|
13
|
-
console.warn(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
60
|
-
const originalPath = id.slice(
|
|
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
|
+
}
|
package/vitest.config.js
ADDED
|
@@ -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
|
+
});
|