瀏覽代碼

feat(dashboard): Improved package setup

Michael Bromley 10 月之前
父節點
當前提交
6f65dcf723

+ 1 - 0
packages/core/src/plugin/index.ts

@@ -10,3 +10,4 @@ export * from './redis-cache-plugin/types';
 export * from './vendure-plugin';
 export * from './plugin-common.module';
 export * from './plugin-utils';
+export * from './plugin-metadata';

+ 4 - 3
packages/dashboard/package.json

@@ -11,10 +11,10 @@
     "lint": "eslint .",
     "preview": "vite preview"
   },
-  "module": "./dist/lib/index.js",
-  "main": "./dist/lib/index.js",
+  "module": "./src/index.ts",
+  "main": "./src/index.ts",
   "exports": {
-    ".": "./dist/lib/index.js",
+    ".": "./src/index.ts",
     "./plugin": "./dist/plugin/index.js"
   },
   "files": [
@@ -50,6 +50,7 @@
     "tailwind-merge": "^3.0.1",
     "tailwindcss": "^4.0.6",
     "tailwindcss-animate": "^1.0.7",
+    "unplugin-swc": "^1.5.1",
     "use-debounce": "^10.0.4"
   },
   "devDependencies": {

+ 3 - 0
packages/dashboard/src/main.tsx

@@ -8,6 +8,9 @@ import { RouterProvider, createRouter } from '@tanstack/react-router';
 import '@/framework/defaults.js';
 import { routeTree } from './routeTree.gen';
 import './styles.css';
+import { dashboardExtensions } from 'virtual:dashboard-extensions';
+
+console.log(`Dashboard extensions:`, dashboardExtensions);
 
 // Set up a Router instance
 const router = createRouter({

+ 3 - 0
packages/dashboard/src/virtual.d.ts

@@ -2,3 +2,6 @@ declare module 'virtual:admin-api-schema' {
     import { SchemaInfo } from '../vite/vite-plugin-admin-api-schema.js';
     export const schemaInfo: SchemaInfo;
 }
+declare module 'virtual:dashboard-extensions' {
+    export const dashboardExtensions: any;
+}

+ 163 - 0
packages/dashboard/vite/config-loader.ts

@@ -0,0 +1,163 @@
+import { Options, parse, transform } from '@swc/core';
+import { BindingIdentifier, ModuleItem, Pattern, Statement } from '@swc/types';
+import fs from 'fs-extra';
+import path from 'path';
+import { pathToFileURL } from 'url';
+
+/**
+ * @description
+ * This function compiles the given Vendure config file and any imported relative files (i.e.
+ * project files, not npm packages) into a temporary directory, and returns the compiled config.
+ *
+ * The reason we need to do this is that Vendure code makes use of TypeScript experimental decorators
+ * (e.g. for NestJS decorators and TypeORM column decorators) which are not supported by esbuild.
+ *
+ * In Vite, when we load some TypeScript into the top-level Vite config file (in the end-user project), Vite
+ * internally uses esbuild to temporarily compile that TypeScript code. Unfortunately, esbuild does not support
+ * these experimental decorators, errors will be thrown as soon as e.g. a TypeORM column decorator is encountered.
+ *
+ * To work around this, we compile the Vendure config file and all its imports using SWC, which does support
+ * these experimental decorators. The compiled files are then loaded by Vite, which is able to handle the compiled
+ * JavaScript output.
+ */
+export async function loadVendureConfig(configFilePath: string) {
+    const outputPath = path.join(import.meta.dirname, './.vendure-dashboard-temp');
+    const configFileName = path.basename(configFilePath);
+    await fs.remove(outputPath);
+    await compileFile(configFilePath, path.join(import.meta.dirname, './.vendure-dashboard-temp'));
+    const compiledConfigFilePath = pathToFileURL(path.join(outputPath, configFileName)).href.replace(
+        /.ts$/,
+        '.js',
+    );
+    // create package.json with type commonjs and save it to the output dir
+    await fs.writeFile(path.join(outputPath, 'package.json'), JSON.stringify({ type: 'commonjs' }, null, 2));
+
+    // We need to figure out the symbol exported by the config file by
+    // analyzing the AST and finding an export with the type "VendureConfig"
+    const ast = await parse(await fs.readFile(configFilePath, 'utf-8'), {
+        syntax: 'typescript',
+        decorators: true,
+    });
+    const configExportedSymbolName = findConfigExport(ast.body);
+    if (!configExportedSymbolName) {
+        throw new Error(`Could not find a variable exported as VendureConfig`);
+    } else {
+        console.log(`Found config export: ${configExportedSymbolName}`);
+    }
+    const config = await import(compiledConfigFilePath).then(m => m[configExportedSymbolName]);
+    return config;
+}
+
+/**
+ * Given the AST of a TypeScript file, finds the name of the variable exported as VendureConfig.
+ */
+function findConfigExport(statements: ModuleItem[]): string | undefined {
+    for (const statement of statements) {
+        if (statement.type === 'ExportDeclaration') {
+            if (statement.declaration.type === 'VariableDeclaration') {
+                for (const declaration of statement.declaration.declarations) {
+                    if (isBindingIdentifier(declaration.id)) {
+                        const typeRef = declaration.id.typeAnnotation?.typeAnnotation;
+                        if (typeRef?.type === 'TsTypeReference') {
+                            if (
+                                typeRef.typeName.type === 'Identifier' &&
+                                typeRef.typeName.value === 'VendureConfig'
+                            ) {
+                                return declaration.id.value;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+    return undefined;
+}
+
+function isBindingIdentifier(id: Pattern): id is BindingIdentifier {
+    return id.type === 'Identifier' && !!(id as BindingIdentifier).typeAnnotation;
+}
+
+export async function compileFile(
+    inputPath: string,
+    outputDir: string,
+    compiledFiles = new Set<string>(),
+): Promise<void> {
+    if (compiledFiles.has(inputPath)) {
+        return;
+    }
+    compiledFiles.add(inputPath);
+
+    // Ensure output directory exists
+    await fs.ensureDir(outputDir);
+
+    // Read the source file
+    const source = await fs.readFile(inputPath, 'utf-8');
+
+    // Transform config
+    const config: Options = {
+        filename: inputPath,
+        sourceMaps: true,
+        jsc: {
+            parser: {
+                syntax: 'typescript',
+                tsx: false,
+                decorators: true,
+            },
+            target: 'es2020',
+            loose: false,
+            transform: {
+                legacyDecorator: true,
+                decoratorMetadata: true,
+            },
+        },
+        module: {
+            type: 'commonjs',
+            strict: true,
+            strictMode: true,
+            lazy: false,
+            noInterop: false,
+        },
+    };
+
+    // Transform the code using SWC
+    const result = await transform(source, config);
+
+    // Generate output file path
+    const relativePath = path.relative(process.cwd(), inputPath);
+    const outputPath = path.join(outputDir, relativePath).replace(/\.ts$/, '.js');
+
+    // Ensure the subdirectory for the output file exists
+    await fs.ensureDir(path.dirname(outputPath));
+
+    // Write the transformed code
+    await fs.writeFile(outputPath, result.code);
+
+    // Write source map if available
+    if (result.map) {
+        await fs.writeFile(`${outputPath}.map`, JSON.stringify(result.map));
+    }
+
+    // Parse the source to find relative imports
+    const ast = await parse(source, { syntax: 'typescript', decorators: true });
+    const importPaths = new Set<string>();
+
+    function collectImports(node: any) {
+        if (node.type === 'ImportDeclaration' && node.source.value.startsWith('.')) {
+            const importPath = path.resolve(path.dirname(inputPath), node.source.value);
+            importPaths.add(importPath + '.ts');
+        }
+        for (const key in node) {
+            if (node[key] && typeof node[key] === 'object') {
+                collectImports(node[key]);
+            }
+        }
+    }
+
+    collectImports(ast);
+
+    // Recursively compile all relative imports
+    for (const importPath of importPaths) {
+        await compileFile(importPath, outputDir, compiledFiles);
+    }
+}

+ 4 - 17
packages/dashboard/vite/vite-plugin-dashboard-metadata.ts

@@ -1,25 +1,10 @@
 import { VendureConfig } from '@vendure/core';
-import { DocumentNode } from 'graphql/index.js';
+import { getPluginDashboardExtensions } from '@vendure/core';
 import { Plugin } from 'vite';
 
 const virtualModuleId = 'virtual:dashboard-extensions';
 const resolvedVirtualModuleId = `\0${virtualModuleId}`;
 
-export interface DashboardBaseRouteDefinition {
-    id: string;
-    title?: string;
-}
-
-export interface DashboardListRouteDefinition extends DashboardBaseRouteDefinition {
-    listQuery: DocumentNode;
-}
-
-export type DashboardRouteDefinition = DashboardListRouteDefinition;
-
-export interface DashboardExtension {
-    routes: DashboardRouteDefinition[];
-}
-
 export async function dashboardMetadataPlugin(options: { config: VendureConfig }): Promise<Plugin> {
     return {
         name: 'vendure-admin-api-schema',
@@ -30,8 +15,10 @@ export async function dashboardMetadataPlugin(options: { config: VendureConfig }
         },
         load(id) {
             if (id === resolvedVirtualModuleId) {
+                const extensions = getPluginDashboardExtensions(options.config.plugins ?? []);
+                console.log(`dashboardMetadataPlugin: ${JSON.stringify(extensions)}`);
                 return `
-
+                    export const dashboardExtensions = ${JSON.stringify(extensions)};
                 `;
             }
         },

+ 13 - 9
packages/dashboard/vite/vite-plugin-vendure-dashboard.ts

@@ -1,26 +1,30 @@
 import { lingui } from '@lingui/vite-plugin';
 import tailwindcss from '@tailwindcss/vite';
-import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
-import { VendureConfig } from '@vendure/core';
 import react from '@vitejs/plugin-react';
 import path from 'path';
 import { PluginOption, UserConfig } from 'vite';
+import { compileFile, loadVendureConfig } from './config-loader.js';
 import { adminApiSchemaPlugin } from './vite-plugin-admin-api-schema.js';
 import { dashboardMetadataPlugin } from './vite-plugin-dashboard-metadata.js';
 
-export function vendureDashboardPlugin(config: VendureConfig): PluginOption[] {
+/**
+ * @description
+ * This is a Vite plugin which configures a set of plugins required to build the Vendure Dashboard.
+ */
+export async function vendureDashboardPlugin(options: {
+    vendureConfigPath: string;
+    vendureConfigExport?: string;
+}): Promise<PluginOption[]> {
+    // const config = await options.loadConfig();
+    const config = await loadVendureConfig(options.vendureConfigPath);
+
     const packageRoot = path
-        .join(import.meta.resolve('@vendure/dashboard'), '../../..')
+        .join(import.meta.resolve('@vendure/dashboard'), '../..')
         .replace(/file:[\/\\]+/, '');
     const linguiConfigPath = path.join(packageRoot, 'lingui.config.js');
-    console.log(`linguiConfigPath: ${linguiConfigPath}`);
-
-    const tanstackRoutesDirectory = path.join(packageRoot, 'src');
 
-    // (import.meta as any).env?.LINGUI_CONFIG = linguiConfigPath;
     process.env.LINGUI_CONFIG = linguiConfigPath;
     return [
-        // TanStackRouterVite({ routesDirectory: tanstackRoutesDirectory }),
         lingui({
             configPath: linguiConfigPath,
         }),

+ 2 - 0
packages/dev-server/dev-config.ts

@@ -22,6 +22,7 @@ import path from 'path';
 import { DataSourceOptions } from 'typeorm';
 
 import { MultivendorPlugin } from './example-plugins/multivendor-plugin/multivendor.plugin';
+import { ReviewsPlugin } from './test-plugins/reviews/reviews-plugin';
 
 /**
  * Config settings used during development
@@ -73,6 +74,7 @@ export const devConfig: VendureConfig = {
         //     platformFeePercent: 10,
         //     platformFeeSKU: 'FEE',
         // }),
+        ReviewsPlugin,
         AssetServerPlugin.init({
             route: 'assets',
             assetUploadDir: path.join(__dirname, 'assets'),

+ 1 - 0
packages/dev-server/package.json

@@ -9,6 +9,7 @@
         "dev:server": "node -r ts-node/register -r dotenv/config index.ts",
         "dev:worker": "node -r ts-node/register -r dotenv/config index-worker.ts",
         "dev": "concurrently npm:dev:*",
+        "dashboard:dev": "vite dev",
         "load-test:1k": "node -r ts-node/register load-testing/run-load-test.ts 1000",
         "load-test:10k": "node -r ts-node/register load-testing/run-load-test.ts 10000",
         "load-test:100k": "node -r ts-node/register load-testing/run-load-test.ts 100000"

+ 36 - 36
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -1,46 +1,46 @@
 import { defineDashboardExtension } from '@vendure/dashboard';
-import { ListPage } from '@vendure/dashboard';
+import { test } from '@vendure/dashboard';
 import gql from 'graphql-tag';
 
 export function Test() {
-    return (
-        <div>
-            <ListPage></ListPage>
-        </div>
-    );
+    return <div>{test}</div>;
 }
 
 export default defineDashboardExtension({
-    id: 'review-list',
-    title: 'Product Reviews',
-    listQuery: gql`
-        query GetProductReviews {
-            productReviews {
-                items {
-                    id
-                    createdAt
-                    updatedAt
-                    product {
-                        id
-                        name
+    routes: [
+        {
+            id: 'review-list',
+            title: 'Product Reviews',
+            listQuery: gql`
+                query GetProductReviews {
+                    productReviews {
+                        items {
+                            id
+                            createdAt
+                            updatedAt
+                            product {
+                                id
+                                name
+                            }
+                            productVariant {
+                                id
+                                name
+                                sku
+                            }
+                            summary
+                            body
+                            rating
+                            authorName
+                            authorLocation
+                            upvotes
+                            downvotes
+                            state
+                            response
+                            responseCreatedAt
+                        }
                     }
-                    productVariant {
-                        id
-                        name
-                        sku
-                    }
-                    summary
-                    body
-                    rating
-                    authorName
-                    authorLocation
-                    upvotes
-                    downvotes
-                    state
-                    response
-                    responseCreatedAt
                 }
-            }
-        }
-    `,
+            `,
+        },
+    ],
 });

+ 5 - 2
packages/dev-server/vite.config.mts

@@ -1,9 +1,12 @@
 import { vendureDashboardPlugin } from '@vendure/dashboard/plugin';
+import { pathToFileURL } from 'url';
 import { defineConfig, UserConfig } from 'vite';
 
 export default defineConfig(async () => {
-    const vendureConfig = await import('./dev-config.js').then(m => m.devConfig);
+    // TODO: hide this ugly stuff internally so we just need to pass a relative path to the plugin
+    const vendureConfigPath = pathToFileURL('./dev-config.ts').href.replace(/^file:[\//]+/, '');
+
     return {
-        plugins: [vendureDashboardPlugin(vendureConfig)],
+        plugins: [vendureDashboardPlugin({ vendureConfigPath })],
     } satisfies UserConfig;
 });