diff --git a/.gitignore b/.gitignore index cfc9c649..32a36580 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules *.launch mxproject coverage +test/results # Packed packages mendix-*.tgz diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs new file mode 100644 index 00000000..c0f2d29a --- /dev/null +++ b/.pnpmfile.cjs @@ -0,0 +1,14 @@ +function beforePacking(pkg) { + // Remove bundled dependency from published package + delete pkg.dependencies['@mendix/widget-typings-generator']; + + console.log('✓ Removed bundled dependency from package.json'); + + return pkg; +} + +module.exports = { + hooks: { + beforePacking + } +}; diff --git a/package.json b/package.json index ce9e5df8..262f87f1 100644 --- a/package.json +++ b/package.json @@ -15,5 +15,6 @@ "repository": { "type": "git", "url": "https://github.com/mendix/widgets-tools.git" - } + }, + "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" } diff --git a/packages/command-tests/commands.js b/packages/command-tests/commands.js index 4831815f..3ba09791 100644 --- a/packages/command-tests/commands.js +++ b/packages/command-tests/commands.js @@ -39,7 +39,7 @@ async function main() { console.log("Preparing..."); const pluggableWidgetsToolsPath = "../pluggable-widgets-tools"; - const { stdout: packOutput } = await execAsync("npm pack", join(__dirname, pluggableWidgetsToolsPath)); + const { stdout: packOutput } = await execAsync("pnpm pack", join(__dirname, pluggableWidgetsToolsPath)); const toolsPackagePath = join(__dirname, pluggableWidgetsToolsPath, packOutput.trim().split(/\n/g).pop()); const workDirs = []; diff --git a/packages/pluggable-widgets-tools/configs/rollup-plugin-widget-typing.mjs b/packages/pluggable-widgets-tools/configs/rollup-plugin-widget-typing.mjs index 0fbe0b05..ef76257e 100644 --- a/packages/pluggable-widgets-tools/configs/rollup-plugin-widget-typing.mjs +++ b/packages/pluggable-widgets-tools/configs/rollup-plugin-widget-typing.mjs @@ -1,8 +1,7 @@ import { promises as fs } from "fs"; import { extname, join } from "path"; import { listDir } from "./shared.mjs"; - -const { transformPackage } = await import(new URL("../dist/typings-generator/index.js", import.meta.url)); +import { transformPackage } from "../dist/widget-typings-generator.js"; export function widgetTyping({ sourceDir }) { let firstRun = true; diff --git a/packages/pluggable-widgets-tools/jest.config.js b/packages/pluggable-widgets-tools/jest.config.js index f6582d1b..ff91b7e9 100644 --- a/packages/pluggable-widgets-tools/jest.config.js +++ b/packages/pluggable-widgets-tools/jest.config.js @@ -7,7 +7,6 @@ const webConfig = { rootDir: ".", testMatch: [ "/src/web/**/*.spec.{ts,tsx}", - "/src/typings-generator/**/*.spec.{ts,tsx}", "/src/utils/**/*.spec.{ts,tsx}" ] }; diff --git a/packages/pluggable-widgets-tools/package.json b/packages/pluggable-widgets-tools/package.json index 7410d883..a0c80cc7 100644 --- a/packages/pluggable-widgets-tools/package.json +++ b/packages/pluggable-widgets-tools/package.json @@ -11,7 +11,8 @@ "pluggable-widgets-tools": "bin/mx-scripts.js" }, "scripts": { - "prepack": "shx rm -rf dist && tsc", + "build:typings": "pnpm --filter=@mendix/widget-typings-generator run build", + "prepack": "shx rm -rf dist && pnpm build:typings && tsc && rollup -c prepack.rollup.mjs", "test": "jest" }, "files": [ @@ -25,6 +26,7 @@ "utils" ], "dependencies": { + "@mendix/widget-typings-generator": "workspace:*", "@babel/core": "^7.26.0", "@babel/plugin-transform-class-properties": "^7.25.9", "@babel/plugin-transform-private-methods": "^7.25.9", diff --git a/packages/pluggable-widgets-tools/prepack.rollup.mjs b/packages/pluggable-widgets-tools/prepack.rollup.mjs new file mode 100644 index 00000000..f763a84b --- /dev/null +++ b/packages/pluggable-widgets-tools/prepack.rollup.mjs @@ -0,0 +1,21 @@ +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; + +export default [ + { + treeshake: false, + input: 'node_modules/@mendix/widget-typings-generator/dist/index.js', + output: { + file: 'dist/widget-typings-generator.js', + format: 'cjs', + exports: 'named', + sourcemap: false + }, + plugins: [ + commonjs(), + nodeResolve({ + preferBuiltins: true + }), + ] + } +]; diff --git a/packages/pluggable-widgets-tools/src/index.ts b/packages/pluggable-widgets-tools/src/index.ts index 23709764..7dec38af 100644 --- a/packages/pluggable-widgets-tools/src/index.ts +++ b/packages/pluggable-widgets-tools/src/index.ts @@ -1,5 +1,6 @@ export * from "./common"; export * from "./native/common"; -export * from "./web/common"; -export * from "./utils/typings"; export * from "./utils"; +export * from "./utils/typings"; +export * from "./web/common"; + diff --git a/packages/vite-config-widgets-web/.gitignore b/packages/vite-config-widgets-web/.gitignore new file mode 100644 index 00000000..84b88500 --- /dev/null +++ b/packages/vite-config-widgets-web/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +test/results diff --git a/packages/vite-config-widgets-web/README.md b/packages/vite-config-widgets-web/README.md new file mode 100644 index 00000000..e6e943d3 --- /dev/null +++ b/packages/vite-config-widgets-web/README.md @@ -0,0 +1,142 @@ +# @mendix/vite-config-widgets-web + +This package provides Vite configuration for building Mendix pluggable web widgets. + + +## Installation + +```bash +pnpm add -D @mendix/vite-config-widgets-web vite +``` + +## Contents + +- `config.web.ts` – thin orchestrator and public entrypoint. +- `types.ts` – shared types used by the config/build modules. +- `config/` – config derivation and mode handling. +- `build/` – editor artifact and MPK build steps. +- `helpers/` – package metadata/path helpers. +- `test/` – integration tests for end-to-end verification. +- `benchmark.js` – helper script to compare build time and output size between the + existing Rollup build and the new Vite build for a given widget. + +## Usage + +Create a local `vite.config.ts` in your widget package: + +```ts +import { createWidgetViteConfig } from "@mendix/vite-config-widgets-web/config.web"; + +export default createWidgetViteConfig(); +``` + +You can optionally override inferred values: + +```ts +import { createWidgetViteConfig } from "@mendix/vite-config-widgets-web/config.web"; + +export default createWidgetViteConfig({ + widgetName: "MyWidget", + runtimeDirectoryName: "mywidget" +}); +``` + +## Build Modes + +This config supports two build modes via the `--mode` flag: + +### Development Mode (`--mode dev`) + +Development builds prioritize debugging and quick iteration: + +- **Minification:** Disabled +- **Source Maps:** Inline (for debugging) +- **Optimization:** Off (preserves code structure) +- **NODE_ENV:** `"development"` +- **Output Size:** Larger MPK (suitable for local dev and CI) + +```json +"scripts": { + "build": "vite build --mode dev" +} +``` + +### Production Mode (`--mode prod` or default) + +Production builds prioritize size and performance: + +- **Minification:** Full (esbuild) +- **Source Maps:** None +- **Optimization:** On (tree-shaking, inlining, etc.) +- **NODE_ENV:** `"production"` +- **Output Size:** Smaller MPK (suitable for releases and marketplace) + +```json +"scripts": { + "release": "vite build --mode prod" +} +``` + +If no mode is specified, production mode is used by default. + +## Internal Module Map + +- `vite.config.ts`: public exports and Vite `defineConfig` wiring +- `config/create.ts`: top-level Vite config object creation +- `config/resolve.ts`: resolves widget/runtime config and build mode +- `config/infer.ts`: infers file paths/artifacts/editor entries +- `build/editor-artifacts.ts`: builds editor preview/config outputs +- `build/mpk.ts`: stages files and creates the `.mpk` +- `helpers/package-json.ts`: package.json loading and widget name resolution +- `types.ts`: cross-module type definitions + +## Development & Testing + +### Build Output Structure + +The build process creates artifacts in `dist/tmp/widgets/`: + +``` +dist/ +├── tmp/ +│ └── widgets/ # Staging directory for MPK +│ ├── {WidgetName}.xml # Widget definition +│ ├── package.xml # Package metadata +│ └── {packagePath}/ # Runtime files +│ └── {runtimeDir}/ +│ ├── {WidgetName}.js # CommonJS bundle +│ └── {WidgetName}.mjs # ES Module bundle +└── {version}/ + └── {WidgetName}.mpk # Final distributable package +``` + +### Integration Tests + +Integration tests verify the package works end-to-end by building a real widget in an isolated environment: + +```bash +# Run integration tests +pnpm test:integration + +# Clean test artifacts +pnpm test:integration:clean +``` + +**How it works:** +1. Creates a temporary directory (using Node.js `tmpdir()`) +2. Packs the vite-config package as a tarball +3. Copies test widget to temp directory +4. Installs dependencies and the packed tarball +5. Builds the test widget +6. Verifies all artifacts (MPK, runtime files, metadata) +7. Copies results to `test/results/` for inspection +8. Cleans up temporary directory + +This ensures the package works correctly when installed from npm without interfering with the monorepo. + +### Package Scripts + +- `pnpm build` - Build the vite config package +- `pnpm test:integration` - Run end-to-end integration tests +- `pnpm test:integration:clean` - Remove test artifacts + diff --git a/packages/vite-config-widgets-web/build.mjs b/packages/vite-config-widgets-web/build.mjs new file mode 100644 index 00000000..551695d5 --- /dev/null +++ b/packages/vite-config-widgets-web/build.mjs @@ -0,0 +1,12 @@ +import * as esbuild from "esbuild"; + +await esbuild.build({ + entryPoints: ["config.web.ts"], + bundle: true, + platform: "node", + format: "esm", + outfile: "dist/config.web.mjs", + external: ["vite", "archiver"], +}); + +console.log("✓ Built dist/config.web.mjs"); diff --git a/packages/vite-config-widgets-web/build/editor-artifacts.ts b/packages/vite-config-widgets-web/build/editor-artifacts.ts new file mode 100644 index 00000000..29708534 --- /dev/null +++ b/packages/vite-config-widgets-web/build/editor-artifacts.ts @@ -0,0 +1,39 @@ +import { build as viteBuild } from "vite"; +import type { EditorBuild } from "../types"; +import { getResolveAlias } from "../config/resolve"; + +export async function buildEditorArtifacts(editorBuilds: EditorBuild[], isDev: boolean = false): Promise { + const editorOutDir = "dist/tmp/widgets"; + const alias = getResolveAlias(); + const minifyMode = isDev ? false : "esbuild"; + const sourcemapMode = isDev ? "inline" : false; + + for (const editorBuild of editorBuilds) { + await viteBuild({ + configFile: false, + resolve: { + alias + }, + build: { + target: "es2019", + minify: minifyMode, + sourcemap: sourcemapMode, + emptyOutDir: false, + outDir: editorOutDir, + lib: { + entry: editorBuild.entry, + formats: [editorBuild.format ?? "cjs"], + fileName: () => editorBuild.outputFile + }, + rollupOptions: { + external: editorBuild.externals, + output: { + format: editorBuild.format ?? "cjs", + entryFileNames: editorBuild.outputFile, + inlineDynamicImports: true + } + } + } + }); + } +} diff --git a/packages/vite-config-widgets-web/build/mpk.ts b/packages/vite-config-widgets-web/build/mpk.ts new file mode 100644 index 00000000..df9ebecd --- /dev/null +++ b/packages/vite-config-widgets-web/build/mpk.ts @@ -0,0 +1,84 @@ +import Archiver from "archiver"; +import { copyFileSync, createWriteStream, existsSync, mkdirSync, rmSync } from "fs"; +import { cp } from "fs/promises"; +import { join, resolve } from "path"; +import type { ResolvedConfig } from "../types"; + +async function copyDir(src: string, dest: string): Promise { + mkdirSync(dest, { recursive: true }); + if (existsSync(src)) { + await cp(src, dest, { recursive: true, force: true }); + } +} + +export async function createMPK(options: ResolvedConfig): Promise { + const distPath = resolve(process.cwd(), "dist"); + const stagingDir = join(distPath, "tmp", "widgets"); + const outputDir = join(process.cwd(), "dist", options.widgetVersion); + const mpkPath = join(outputDir, options.mpkName); + + mkdirSync(stagingDir, { recursive: true }); + mkdirSync(outputDir, { recursive: true }); + + for (const file of options.metadataFiles) { + const srcPath = resolve(process.cwd(), file.src); + const destPath = join(stagingDir, file.dest); + mkdirSync(join(stagingDir, file.dest.split("/").slice(0, -1).join("/")), { recursive: true }); + if (existsSync(srcPath)) { + copyFileSync(srcPath, destPath); + } + } + + for (const requiredArtifact of options.requiredArtifacts ?? []) { + const requiredPath = join(stagingDir, requiredArtifact); + if (!existsSync(requiredPath)) { + throw new Error(`Missing compiled artifact: ${requiredPath}`); + } + } + + for (const removePath of options.removeBeforeCopy ?? []) { + const absolutePath = join(stagingDir, removePath); + if (existsSync(absolutePath)) { + rmSync(absolutePath); + } + } + + await new Promise((resolvePromise, reject) => { + const output = createWriteStream(mpkPath); + const archive = (Archiver as unknown as (format: string, options: { zlib: { level: number } }) => any)("zip", { + zlib: { level: 9 } + }); + + output.on("close", () => { + console.log(`Created ${mpkPath} (${archive.pointer()} bytes)`); + resolvePromise(); + }); + + archive.on("error", reject); + archive.pipe(output); + archive.directory(stagingDir, false); + archive.finalize(); + }); + + return mpkPath; +} + +export async function deployMPKToMxProject(mpkPath: string): Promise { + const mxProjectPath = process.env.MX_PROJECT_PATH; + + if (!mxProjectPath) { + return; + } + + const widgetsDir = resolve(mxProjectPath, "widgets"); + const fileName = mpkPath.split("/").pop(); + + if (!fileName) { + throw new Error(`Invalid MPK path: ${mpkPath}`); + } + + mkdirSync(widgetsDir, { recursive: true }); + const targetPath = join(widgetsDir, fileName); + copyFileSync(mpkPath, targetPath); + console.log(`Deployed ${fileName} to ${widgetsDir}`); +} diff --git a/packages/vite-config-widgets-web/config.web.ts b/packages/vite-config-widgets-web/config.web.ts new file mode 100644 index 00000000..d20f5920 --- /dev/null +++ b/packages/vite-config-widgets-web/config.web.ts @@ -0,0 +1,10 @@ +import { defineConfig, type ConfigEnv } from "vite"; +import { createConfig } from "./config/create"; +import type { WidgetViteConfigOptions } from "./types"; + +export function createWidgetViteConfig(options: WidgetViteConfigOptions = {}) { + return defineConfig((env: ConfigEnv) => createConfig(options, env)); +} + +// Default export supports direct CLI usage: +export default defineConfig((env: ConfigEnv) => createConfig({}, env)); diff --git a/packages/vite-config-widgets-web/config/create.ts b/packages/vite-config-widgets-web/config/create.ts new file mode 100644 index 00000000..432c3fc4 --- /dev/null +++ b/packages/vite-config-widgets-web/config/create.ts @@ -0,0 +1,77 @@ +import type { ConfigEnv, UserConfig } from "vite"; +import type { WidgetViteConfigOptions } from "../types"; +import { buildEditorArtifacts } from "../build/editor-artifacts"; +import { createMPK, deployMPKToMxProject } from "../build/mpk"; +import { getResolveAlias, isBuildDev, resolveConfig } from "./resolve"; +import { promises as fs } from "fs"; +import { join, extname } from "path"; +import { readdirSync, statSync } from "fs"; + +export function createConfig(options: WidgetViteConfigOptions, env: ConfigEnv): UserConfig { + const { mode } = env; + + const isDev = isBuildDev(mode); + const resolvedConfig = resolveConfig(options, isDev); + const alias = getResolveAlias(); + const minifyMode = isDev ? false : "esbuild"; + const sourcemapMode = isDev ? "inline" : false; + + return { + define: resolvedConfig.define, + resolve: { + alias + }, + build: { + target: "es2019", + minify: minifyMode, + sourcemap: sourcemapMode, + lib: { + entry: resolvedConfig.runtimeEntry, + name: resolvedConfig.widgetName + }, + outDir: resolvedConfig.runtimeOutDir, + rollupOptions: { + output: resolvedConfig.runtimeOutputs.map(runtimeOutput => ({ + format: runtimeOutput.format, + entryFileNames: runtimeOutput.entryFileName, + inlineDynamicImports: true + })), + external: resolvedConfig.runtimeExternals + } + }, + plugins: [ + { + name: "vite-plugin-widget-typings", + apply: "build", + async buildStart() { + const packageXmlPath = join(resolvedConfig.sourceDir, "package.xml"); + try { + const { transformPackage } = await import("../dist/widget-typings-generator.js"); + const packageXmlContent = await fs.readFile(packageXmlPath, "utf8"); + await transformPackage(packageXmlContent, resolvedConfig.sourceDir); + } catch (error) { + // Skip if package.xml doesn't exist (not a widget project) + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + console.warn("Widget typings generation failed:", error); + } + } + } + }, + { + name: "vite-plugin-mpk-builder", + apply: "build", + enforce: "post", + async closeBundle() { + if (resolvedConfig.editorBuilds.length > 0) { + console.log("Building editor artifacts..."); + await buildEditorArtifacts(resolvedConfig.editorBuilds, isDev); + } + + console.log("Building MPK..."); + const mpkPath = await createMPK(resolvedConfig); + await deployMPKToMxProject(mpkPath); + } + } + ] + }; +} diff --git a/packages/vite-config-widgets-web/config/infer.ts b/packages/vite-config-widgets-web/config/infer.ts new file mode 100644 index 00000000..2780d313 --- /dev/null +++ b/packages/vite-config-widgets-web/config/infer.ts @@ -0,0 +1,75 @@ +import { existsSync } from "fs"; +import type { EditorBuild, FileCopy } from "../types"; +import { toPackagePathDir } from "../helpers/package-json"; + +export function inferPrimaryRuntimeFormat(): "cjs" | "amd" { + if (process.env.VITE_RUNTIME_FORMAT === "cjs") { + return "cjs"; + } + + return "amd"; +} + +export function inferMetadataFiles(widgetName: string): FileCopy[] { + return [ + { src: `src/${widgetName}.xml`, dest: `${widgetName}.xml` }, + { src: `src/${widgetName}.icon.png`, dest: `${widgetName}.icon.png` }, + { src: `src/${widgetName}.icon.dark.png`, dest: `${widgetName}.icon.dark.png` }, + { src: `src/${widgetName}.tile.png`, dest: `${widgetName}.tile.png` }, + { src: `src/${widgetName}.tile.dark.png`, dest: `${widgetName}.tile.dark.png` }, + { src: "../../../LICENSE", dest: "License.txt" }, + { src: "src/package.xml", dest: "package.xml" } + ]; +} + +export function inferRequiredArtifacts( + widgetName: string, + packagePath: string, + runtimeDirectoryName: string, + editorBuilds: EditorBuild[] +): string[] { + const packagePathDir = toPackagePathDir(packagePath); + const widgetDir = runtimeDirectoryName; + + const editorArtifacts = editorBuilds.map(editorBuild => editorBuild.outputFile); + + return [ + ...editorArtifacts, + `${packagePathDir}/${widgetDir}/${widgetName}.js`, + `${packagePathDir}/${widgetDir}/${widgetName}.mjs` + ]; +} + +export function inferRuntimeOutDir(packagePath: string, runtimeDirectoryName: string): string { + const packagePathDir = toPackagePathDir(packagePath); + return `dist/tmp/widgets/${packagePathDir}/${runtimeDirectoryName}`; +} + +export function inferEditorBuilds(widgetName: string): EditorBuild[] { + const editorBuilds: EditorBuild[] = []; + + const editorPreviewEntry = `src/${widgetName}.editorPreview.tsx`; + if (existsSync(editorPreviewEntry)) { + editorBuilds.push({ + entry: editorPreviewEntry, + outputFile: `${widgetName}.editorPreview.js`, + externals: [/^mendix($|\/)/, /^react$/, /^react-dom$/] + }); + } + + const editorConfigEntry = `src/${widgetName}.editorConfig.ts`; + if (existsSync(editorConfigEntry)) { + editorBuilds.push({ + entry: editorConfigEntry, + outputFile: `${widgetName}.editorConfig.js`, + externals: [/^mendix($|\/)/, /^react$/, /^react-dom$/] + }); + } + + return editorBuilds; +} + +export function inferRemoveBeforeCopy(packageName: string): string[] { + const widgetPackageName = packageName.split("/").pop(); + return widgetPackageName ? [`${widgetPackageName}.css`] : []; +} diff --git a/packages/vite-config-widgets-web/config/resolve.ts b/packages/vite-config-widgets-web/config/resolve.ts new file mode 100644 index 00000000..a1f1d4a3 --- /dev/null +++ b/packages/vite-config-widgets-web/config/resolve.ts @@ -0,0 +1,68 @@ +import { resolve } from "path"; +import type { ResolvedConfig, WidgetViteConfigOptions } from "../types"; +import { readWidgetPackageJson, resolveWidgetName } from "../helpers/package-json"; +import { + inferEditorBuilds, + inferMetadataFiles, + inferPrimaryRuntimeFormat, + inferRemoveBeforeCopy, + inferRequiredArtifacts, + inferRuntimeOutDir +} from "./infer"; + +export function getResolveAlias(): { find: RegExp; replacement: string }[] { + return [ + { + find: /^~(.+)/, + replacement: "$1" + }, + { + find: /^src\//, + replacement: `${resolve(process.cwd(), "src")}/` + } + ]; +} + +export function isBuildDev(mode: string): boolean { + return mode === "dev"; +} + +export function resolveConfig(options: WidgetViteConfigOptions, isDev: boolean = false): ResolvedConfig { + const widgetPackageJson = readWidgetPackageJson(); + const widgetName = resolveWidgetName(options.widgetName, widgetPackageJson.widgetName); + const primaryRuntimeFormat = inferPrimaryRuntimeFormat(); + const editorBuilds = inferEditorBuilds(widgetName); + const runtimeDirectoryName = options.runtimeDirectoryName ?? widgetName.toLowerCase(); + + return { + widgetName, + widgetVersion: widgetPackageJson.version, + mpkName: widgetPackageJson.mxpackage?.mpkName ?? `${widgetName}.mpk`, + sourceDir: resolve(process.cwd(), "src"), + runtimeEntry: `src/${widgetName}.tsx`, + runtimeOutDir: inferRuntimeOutDir(widgetPackageJson.packagePath, runtimeDirectoryName), + runtimeOutputs: [ + { + format: primaryRuntimeFormat, + entryFileName: `${widgetName}.js` + }, + { + format: "es", + entryFileName: `${widgetName}.mjs` + } + ], + runtimeExternals: ["react", "react-dom", "@mendix/widget-plugin-component-kit", "big.js", /^mendix($|\/)/], + metadataFiles: inferMetadataFiles(widgetName), + editorBuilds, + requiredArtifacts: inferRequiredArtifacts( + widgetName, + widgetPackageJson.packagePath, + runtimeDirectoryName, + editorBuilds + ), + removeBeforeCopy: inferRemoveBeforeCopy(widgetPackageJson.name), + define: { + "process.env.NODE_ENV": JSON.stringify(isDev ? "development" : "production") + } + }; +} diff --git a/packages/vite-config-widgets-web/helpers/package-json.ts b/packages/vite-config-widgets-web/helpers/package-json.ts new file mode 100644 index 00000000..b4a4d6f1 --- /dev/null +++ b/packages/vite-config-widgets-web/helpers/package-json.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; +import type { WidgetPackageJson } from "../types"; + +export function readWidgetPackageJson(): WidgetPackageJson { + const packageJsonPath = resolve(process.cwd(), "package.json"); + const packageJsonText = readFileSync(packageJsonPath, "utf-8"); + return JSON.parse(packageJsonText) as WidgetPackageJson; +} + +export function toPackagePathDir(packagePath: string): string { + return packagePath.replace(/\./g, "/"); +} + +export function resolveWidgetName( + optionsWidgetName: string | undefined, + packageWidgetName: string | undefined +): string { + const widgetName = optionsWidgetName ?? packageWidgetName; + + if (!widgetName) { + throw new Error( + "Widget name is missing. Pass `widgetName` to createWidgetViteConfig() or add `widgetName` to package.json." + ); + } + + return widgetName; +} diff --git a/packages/vite-config-widgets-web/package.json b/packages/vite-config-widgets-web/package.json new file mode 100644 index 00000000..0ff58a7a --- /dev/null +++ b/packages/vite-config-widgets-web/package.json @@ -0,0 +1,44 @@ +{ + "name": "@mendix/vite-config-widgets-web", + "version": "0.0.0", + "description": "Shared Vite configurations for web widgets", + "main": "./dist/config.web.mjs", + "exports": { + "./config.web": "./dist/config.web.mjs" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "node build.mjs", + "build:typings": "pnpm --filter=@mendix/widget-typings-generator run build", + "prepack": "premove dist && pnpm build:typings && node build.mjs && node prepack.mjs", + "test:integration": "premove test/results && node test/run-integration-test.mjs" + }, + "keywords": [ + "vite", + "mendix", + "widgets", + "config", + "web" + ], + "repository": { + "type": "git", + "url": "https://github.com/mendix/widgets-tools.git", + "directory": "packages/vite-config-widgets-web" + }, + "license": "Apache-2.0", + "dependencies": { + "@mendix/widget-typings-generator": "workspace:*", + "archiver": "^7.0.1" + }, + "peerDependencies": { + "vite": "^7.0.0" + }, + "devDependencies": { + "esbuild": "^0.24.0", + "premove": "^4.0.0", + "vite": "^7.3.1" + } +} diff --git a/packages/vite-config-widgets-web/prepack.mjs b/packages/vite-config-widgets-web/prepack.mjs new file mode 100644 index 00000000..1627a05c --- /dev/null +++ b/packages/vite-config-widgets-web/prepack.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import { build } from "esbuild"; + +await build({ + entryPoints: ["node_modules/@mendix/widget-typings-generator/dist/index.js"], + bundle: true, + platform: "node", + target: "node20", + format: "cjs", + outfile: "dist/widget-typings-generator.js", + external: [], + sourcemap: false +}); diff --git a/packages/vite-config-widgets-web/test/run-integration-test.mjs b/packages/vite-config-widgets-web/test/run-integration-test.mjs new file mode 100755 index 00000000..121f71ad --- /dev/null +++ b/packages/vite-config-widgets-web/test/run-integration-test.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +import { execSync } from "child_process"; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageRoot = join(__dirname, ".."); +const testWidgetSourceDir = join(__dirname, "test-checkbox"); +const resultsDir = join(__dirname, "results"); + +function log(message) { + console.log(` ${message}`); +} + +function logSuccess(message) { + console.log(` ✓ ${message}`); +} + +function logError(message) { + console.error(` ❌ ${message}`); +} + +function exec(command, cwd, description) { + if (description) { + log(`${description}...`); + } + log(`Running: ${command}`); + try { + execSync(command, { + cwd, + stdio: "inherit", + env: { ...process.env, FORCE_COLOR: "1" } + }); + if (description) { + logSuccess(description); + } + } catch (error) { + logError(`Command failed: ${command}`); + process.exit(1); + } +} + +function checkFile(path, description) { + if (!existsSync(path)) { + logError(`Missing ${description}`); + logError(`Expected: ${path}`); + process.exit(1); + } + const stats = statSync(path); + const size = stats.size < 1024 ? `${stats.size} bytes` : `${(stats.size / 1024).toFixed(1)} KB`; + logSuccess(`${description} (${size})`); + return path; +} + +function verifyZipFile(path) { + const buffer = readFileSync(path); + if (buffer[0] === 0x50 && buffer[1] === 0x4B) { + logSuccess("MPK is valid ZIP archive"); + return true; + } + logError("MPK is not a valid ZIP archive"); + process.exit(1); +} + +console.log("\n🧪 Integration Test for @mendix/vite-config-widgets-web"); +console.log("=".repeat(60)); + +// Phase 0: Setup +console.log("\n📦 Phase 0: Setup test environment"); +const tempDir = join(tmpdir(), `vite-config-widgets-web-test-${Date.now()}`); +const testWidgetDir = join(tempDir, "test-checkbox"); + +log(`Creating temp directory: ${tempDir.split("/").pop()}`); +mkdirSync(tempDir, { recursive: true }); +logSuccess("Created temp directory"); + +if (existsSync(resultsDir)) { + rmSync(resultsDir, { recursive: true, force: true }); +} +mkdirSync(resultsDir, { recursive: true }); +logSuccess("Results directory ready"); + +// Phase 1: Copy test widget +console.log("\n📦 Phase 1: Copy test widget"); +log("Copying test-checkbox to temp directory..."); +cpSync(testWidgetSourceDir, testWidgetDir, { + recursive: true, + filter: (src) => !src.includes("node_modules") && !src.includes("dist") +}); +logSuccess("Copied test widget"); + +// Phase 2: Pack vite-config package +console.log("\n📦 Phase 2: Pack vite-config package"); +exec("pnpm pack --pack-destination " + tempDir, packageRoot, "Packing vite-config-widgets-web"); + +const tarballName = `mendix-vite-config-widgets-web-${JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf-8")).version}.tgz`; +const tarballPath = join(tempDir, tarballName); +checkFile(tarballPath, "Package tarball"); + +log("Updating test widget to use packed tarball..."); +const viteConfigPath = join(testWidgetDir, "vite.config.ts"); +const viteConfig = readFileSync(viteConfigPath, "utf-8"); +const updatedViteConfig = viteConfig.replace( + 'from "../../dist/config.web.mjs"', + 'from "@mendix/vite-config-widgets-web/config.web"' +); +writeFileSync(viteConfigPath, updatedViteConfig); + +const testPackageJsonPath = join(testWidgetDir, "package.json"); +const testPackageJson = JSON.parse(readFileSync(testPackageJsonPath, "utf-8")); +testPackageJson.devDependencies["@mendix/vite-config-widgets-web"] = `file:${tarballPath}`; +writeFileSync(testPackageJsonPath, JSON.stringify(testPackageJson, null, 2)); +logSuccess("Updated vite.config.ts and package.json"); + +// Phase 3: Install dependencies +console.log("\n📦 Phase 3: Install test widget dependencies"); +exec("npm install", testWidgetDir, "Installing dependencies"); + +// Phase 4: Build test widget +console.log("\n📦 Phase 4: Build test widget"); +exec("npm run build", testWidgetDir, "Building widget with Vite"); + +// Phase 5: Verify artifacts +console.log("\n📦 Phase 5: Verify build artifacts"); +const distDir = join(testWidgetDir, "dist"); +const mpkPath = checkFile(join(distDir, "1.0.0/TestCheckbox.mpk"), "MPK file"); +checkFile(join(distDir, "tmp/widgets/mendix/testcheckbox/testcheckbox/TestCheckbox.js"), "Runtime JS"); +checkFile(join(distDir, "tmp/widgets/mendix/testcheckbox/testcheckbox/TestCheckbox.mjs"), "Runtime MJS"); +checkFile(join(distDir, "tmp/widgets/TestCheckbox.xml"), "Widget XML"); +checkFile(join(distDir, "tmp/widgets/package.xml"), "Package XML"); +checkFile(join(testWidgetDir, "typings/TestCheckboxProps.d.ts"), "TypeScript typings"); +verifyZipFile(mpkPath); + +// Phase 6: Copy results +console.log("\n📦 Phase 6: Copy results"); +log("Copying artifacts to test/results..."); +cpSync(distDir, resultsDir, { recursive: true }); +logSuccess("Copied build artifacts"); + +log("Cleaning up temp directory..."); +rmSync(tempDir, { recursive: true, force: true }); +logSuccess("Removed temp directory"); + +console.log("\n" + "=".repeat(60)); +console.log("✅ Integration test PASSED"); +console.log("=".repeat(60)); +console.log(`\nResults: ${resultsDir}`); +console.log(`MPK: ${join(resultsDir, "1.0.0/TestCheckbox.mpk")}`); diff --git a/packages/vite-config-widgets-web/test/test-checkbox/package.json b/packages/vite-config-widgets-web/test/test-checkbox/package.json new file mode 100644 index 00000000..694eedae --- /dev/null +++ b/packages/vite-config-widgets-web/test/test-checkbox/package.json @@ -0,0 +1,18 @@ +{ + "name": "@mendix/test-checkbox", + "type": "module", + "widgetName": "TestCheckbox", + "version": "1.0.0", + "packagePath": "mendix.testcheckbox", + "private": true, + "scripts": { + "build": "vite build" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.0.0", + "vite": "^7.3.1" + } +} diff --git a/packages/vite-config-widgets-web/test/test-checkbox/src/TestCheckbox.tsx b/packages/vite-config-widgets-web/test/test-checkbox/src/TestCheckbox.tsx new file mode 100644 index 00000000..e3c7c2c5 --- /dev/null +++ b/packages/vite-config-widgets-web/test/test-checkbox/src/TestCheckbox.tsx @@ -0,0 +1,22 @@ +import { createElement, ReactElement } from "react"; + +export interface TestCheckboxProps { + checked: boolean; + label: string; + onChange?: () => void; +} + +export function TestCheckbox(props: TestCheckboxProps): ReactElement { + return createElement( + "div", + { className: "test-checkbox-container" }, + createElement("label", null, + createElement("input", { + type: "checkbox", + checked: props.checked, + onChange: props.onChange + }), + createElement("span", null, props.label) + ) + ); +} diff --git a/packages/vite-config-widgets-web/test/test-checkbox/src/TestCheckbox.xml b/packages/vite-config-widgets-web/test/test-checkbox/src/TestCheckbox.xml new file mode 100644 index 00000000..40d7184d --- /dev/null +++ b/packages/vite-config-widgets-web/test/test-checkbox/src/TestCheckbox.xml @@ -0,0 +1,29 @@ + + + Test Checkbox + A simple test checkbox widget + + + + + Checked + Boolean attribute for checkbox state + + + + + + Label + Label text for the checkbox + + + + On Change + Action to execute when checkbox is toggled + + + + diff --git a/packages/vite-config-widgets-web/test/test-checkbox/src/package.xml b/packages/vite-config-widgets-web/test/test-checkbox/src/package.xml new file mode 100644 index 00000000..c56005d8 --- /dev/null +++ b/packages/vite-config-widgets-web/test/test-checkbox/src/package.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/vite-config-widgets-web/test/test-checkbox/tsconfig.json b/packages/vite-config-widgets-web/test/test-checkbox/tsconfig.json new file mode 100644 index 00000000..765dd640 --- /dev/null +++ b/packages/vite-config-widgets-web/test/test-checkbox/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/packages/vite-config-widgets-web/test/test-checkbox/vite.config.ts b/packages/vite-config-widgets-web/test/test-checkbox/vite.config.ts new file mode 100644 index 00000000..1dbd749f --- /dev/null +++ b/packages/vite-config-widgets-web/test/test-checkbox/vite.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from "vite"; +import { createWidgetViteConfig } from "../../dist/config.web.mjs"; + +export default createWidgetViteConfig(); diff --git a/packages/vite-config-widgets-web/types.ts b/packages/vite-config-widgets-web/types.ts new file mode 100644 index 00000000..81078ec9 --- /dev/null +++ b/packages/vite-config-widgets-web/types.ts @@ -0,0 +1,47 @@ +export type EditorBuild = { + entry: string; + outputFile: string; + externals: Array; + format?: "cjs" | "es"; +}; + +export type RuntimeOutput = { + format: "cjs" | "es" | "amd"; + entryFileName: string; +}; + +export type FileCopy = { + src: string; + dest: string; +}; + +export type WidgetPackageJson = { + name: string; + widgetName?: string; + version: string; + packagePath: string; + mxpackage?: { + mpkName?: string; + }; +}; + +export type WidgetViteConfigOptions = { + widgetName?: string; + runtimeDirectoryName?: string; +}; + +export type ResolvedConfig = { + widgetName: string; + widgetVersion: string; + mpkName: string; + sourceDir: string; + runtimeEntry: string; + runtimeOutDir: string; + runtimeOutputs: RuntimeOutput[]; + runtimeExternals: Array; + metadataFiles: FileCopy[]; + editorBuilds: EditorBuild[]; + requiredArtifacts: string[]; + removeBeforeCopy: string[]; + define: Record; +}; diff --git a/packages/widget-typings-generator/README.md b/packages/widget-typings-generator/README.md new file mode 100644 index 00000000..80396869 --- /dev/null +++ b/packages/widget-typings-generator/README.md @@ -0,0 +1,81 @@ +# @mendix/widget-typings-generator + +TypeScript typings generator for Mendix Pluggable Widgets. + +## Overview + +Generates TypeScript definition files (`.d.ts`) from Mendix widget XML schema files, providing type-safe development experience for widget creators. + +## Usage + +### Basic Usage + +```typescript +import { transformPackage } from "@mendix/widget-typings-generator"; +import { readFile } from "fs/promises"; + +// Read your widget's package.xml +const packageXml = await readFile("./src/{WidgetName}.xml", "utf-8"); + +// Generate TypeScript definitions +await transformPackage(packageXml, "./src"); +``` + +This will: +1. Parse `{WidgetName}.xml` to find all widget XML files +2. Generate TypeScript interfaces for each widget +3. Create `.d.ts` files in `../typings/{WidgetName}.xml/` directory + +### Generated Types + +For a widget named `MyWidget`, it generates: +- **Web:** `MyWidgetContainerProps` (runtime) and `MyWidgetPreviewProps` (Studio Pro) +- **Native:** `MyWidgetProps