create-middag-ui 0.12.0 → 0.14.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/README.md CHANGED
@@ -86,7 +86,7 @@ npx @middag-io/react upgrade # Check for updates
86
86
 
87
87
  ## Documentation
88
88
 
89
- - **[Live Demo](https://middag-react-mock.pages.dev)** — 24 screens showing all block types
89
+ - **[Live Demo](https://ui-demo.middag.io)** — 24 screens showing all block types
90
90
  - **[Full Documentation](https://ui-docs.middag.io)** — Getting started, host guides, API reference
91
91
  - **[GitHub](https://github.com/middag-io/middag-react)** — Source code and issues
92
92
 
package/cli.js CHANGED
@@ -20,32 +20,35 @@
20
20
  */
21
21
 
22
22
  import { join } from "node:path";
23
- import { detectHost, HOSTS } from "./lib/detect.js";
24
- import { ask, select, confirm } from "./lib/prompts.js";
23
+ import { detectHost, detectMoodleComponent, HOSTS } from "./lib/detect.js";
24
+ import { ask, confirm, select } from "./lib/prompts.js";
25
25
  import { runTokenFlow } from "./lib/auth.js";
26
26
  import {
27
27
  createTargetDir,
28
- scaffoldPackageJson,
29
- scaffoldTsconfig,
30
- scaffoldViteConfig,
31
- scaffoldEslintConfig,
32
- scaffoldPrettierConfig,
33
- scaffoldIndexHtml,
28
+ scaffoldDemoDirectPage,
34
29
  scaffoldDemoFiles,
35
- scaffoldPageExamples,
36
- scaffoldFreeApp,
37
- scaffoldFreeAdapters,
38
30
  scaffoldDevShell,
31
+ scaffoldEslintConfig,
32
+ scaffoldFreeAdapters,
33
+ scaffoldFreeApp,
34
+ scaffoldFreeRegister,
39
35
  scaffoldHostEntry,
40
- scaffoldHostViteConfig,
41
36
  scaffoldHostThemeCSS,
42
- scaffoldFreeRegister,
37
+ scaffoldHostViteConfig,
38
+ scaffoldIndexHtml,
39
+ scaffoldMoodleAdapters,
40
+ scaffoldMoodlePlugin,
41
+ scaffoldMoodleTailwind,
42
+ scaffoldPackageJson,
43
+ scaffoldPageExamples,
43
44
  scaffoldPageResolver,
45
+ scaffoldPrettierConfig,
44
46
  scaffoldRouteHelper,
45
- scaffoldDemoDirectPage,
47
+ scaffoldTsconfig,
48
+ scaffoldViteConfig,
46
49
  } from "./lib/scaffold.js";
47
50
  import { runNpmInstall } from "./lib/install.js";
48
- import { log, success, heading, blank, info } from "./lib/ui.js";
51
+ import { blank, heading, info, log, success } from "./lib/ui.js";
49
52
 
50
53
  const TOTAL_STEPS = 10;
51
54
  const cwd = process.cwd();
@@ -73,6 +76,20 @@ if (hostKey) {
73
76
 
74
77
  const host = HOSTS[hostKey];
75
78
 
79
+ // Moodle: detect frankenstyle component name (e.g. "local_middag", "mod_assign")
80
+ let moodleComponent = null;
81
+ if (hostKey === "moodle") {
82
+ moodleComponent = detectMoodleComponent(cwd);
83
+ if (moodleComponent) {
84
+ success(`Moodle component: ${ moodleComponent }`);
85
+ } else {
86
+ info("Could not detect Moodle component from version.php");
87
+ const answer = await ask(" Frankenstyle component (e.g. local_yourplugin): ");
88
+ moodleComponent = answer || "local_myplugin";
89
+ success(`Component: ${ moodleComponent }`);
90
+ }
91
+ }
92
+
76
93
  // ── Step 2: Ask directory ────────────────────────────────────────────────
77
94
 
78
95
  heading(2, TOTAL_STEPS, "Target directory");
@@ -156,7 +173,9 @@ scaffoldPageExamples(targetDir);
156
173
  // Shared files (both PRO and FREE)
157
174
  scaffoldPageResolver(targetDir);
158
175
  scaffoldDemoDirectPage(targetDir);
159
- scaffoldRouteHelper(targetDir, hostKey);
176
+ // Convert frankenstyle "local_middag" → Moodle path "local/middag"
177
+ const moodlePath = moodleComponent ? moodleComponent.replace("_", "/") : null;
178
+ scaffoldRouteHelper(targetDir, hostKey, moodlePath);
160
179
 
161
180
  if (isPro) {
162
181
  try {
@@ -191,6 +210,14 @@ scaffoldHostEntry(targetDir, hostKey);
191
210
  scaffoldHostViteConfig(targetDir, hostKey, host);
192
211
  scaffoldHostThemeCSS(targetDir, hostKey, host);
193
212
 
213
+ // Moodle-specific: AMD plugin + Moodle adapters (ajax, strings, notification)
214
+ if (hostKey === "moodle" && moodleComponent) {
215
+ scaffoldMoodlePlugin(targetDir, moodleComponent);
216
+ scaffoldMoodleTailwind(targetDir);
217
+ scaffoldMoodleAdapters(targetDir, moodleComponent);
218
+ success(`Moodle: AMD plugin + Tailwind + adapters for ${ moodleComponent }`);
219
+ }
220
+
194
221
  // ── Step 9: npm install ──────────────────────────────────────────────────
195
222
 
196
223
  heading(9, TOTAL_STEPS, "Installing dependencies");
@@ -237,6 +264,16 @@ console.log(` Production build for ${host.name}:`);
237
264
  console.log(` npm run ${hostBuildScript} \u2192 build for ${host.name}`);
238
265
  console.log(` npm run ${hostWatchScript} \u2192 rebuild on change`);
239
266
 
267
+ if (hostKey === "moodle" && moodleComponent) {
268
+ blank();
269
+ console.log(` Moodle integration (${ moodleComponent }):`);
270
+ console.log(" AMD chunks \u2192 amd/src/ + amd/build/");
271
+ console.log(" CSS \u2192 styles/middag-app.css");
272
+ console.log(" AMD plugin: plugins/vite-plugin-moodle-amd.ts");
273
+ console.log(" Moodle adapters: src/lib/moodle/ (ajax, strings, notification)");
274
+ console.log(" Tailwind entry: src/tailwind.css");
275
+ }
276
+
240
277
  blank();
241
278
  console.log(` Integrate with your ${host.name} plugin:`);
242
279
  console.log(" 1. Import { ContractPage } from '@middag-io/react'");
package/lib/detect.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { existsSync, readFileSync } from "node:fs";
13
- import { join, dirname } from "node:path";
13
+ import { dirname, join } from "node:path";
14
14
 
15
15
  /** Max ancestor levels to walk when searching for platform markers. */
16
16
  const MAX_DEPTH = 5;
@@ -54,6 +54,26 @@ function isMoodleRootVersion(filePath) {
54
54
  }
55
55
  }
56
56
 
57
+ /**
58
+ * Extract Moodle plugin frankenstyle component name from version.php.
59
+ * Returns e.g. "local_middag", "mod_assign", or null if not found.
60
+ *
61
+ * @param {string} cwd - Directory to check (should be the Moodle plugin root)
62
+ * @returns {string|null} Frankenstyle component name
63
+ */
64
+ export function detectMoodleComponent(cwd) {
65
+ const versionFile = join(cwd, "version.php");
66
+ if (!existsSync(versionFile)) return null;
67
+
68
+ try {
69
+ const content = readFileSync(versionFile, "utf-8");
70
+ const match = content.match(/\$plugin\s*->\s*component\s*=\s*['"]([^'"]+)['"]/);
71
+ return match ? match[1] : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
57
77
  /**
58
78
  * Detect host platform by checking cwd and ancestor directories.
59
79
  *
package/lib/scaffold.js CHANGED
@@ -8,9 +8,9 @@
8
8
  */
9
9
 
10
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
- import { join, basename, dirname } from "node:path";
11
+ import { basename, dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
- import { success, warn, error } from "./ui.js";
13
+ import { error, success, warn } from "./ui.js";
14
14
 
15
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
16
 
@@ -94,6 +94,13 @@ export function scaffoldPackageJson(targetDir, host, cwd, registryPath, hostKey)
94
94
  deps["sonner"] = "^2.0.0";
95
95
  }
96
96
 
97
+ // Moodle AMD build needs Tailwind Vite plugin for CSS processing
98
+ const moodleDevDeps = {};
99
+ if (hostKey === "moodle") {
100
+ moodleDevDeps["@tailwindcss/vite"] = "^4.0.0";
101
+ moodleDevDeps["tailwindcss"] = "^4.0.0";
102
+ }
103
+
97
104
  const scripts = {
98
105
  dev: "vite",
99
106
  build: "vite build",
@@ -140,6 +147,7 @@ export function scaffoldPackageJson(targetDir, host, cwd, registryPath, hostKey)
140
147
  eslint: "^9.0.0",
141
148
  prettier: "^3.0.0",
142
149
  "prettier-plugin-tailwindcss": "^0.6.0",
150
+ ...moodleDevDeps,
143
151
  },
144
152
  };
145
153
 
@@ -166,7 +174,7 @@ export function scaffoldTsconfig(targetDir) {
166
174
  paths: { "@/*": ["./src/*"] },
167
175
  baseUrl: ".",
168
176
  },
169
- include: ["src"],
177
+ include: ["src", "mock"],
170
178
  };
171
179
 
172
180
  writeFile(filePath, JSON.stringify(tsconfig, null, 2) + "\n", "tsconfig.json");
@@ -443,6 +451,22 @@ export type { PageContract, BlockDescriptor, SharedProps } from "@middag-io/reac
443
451
  *
444
452
  * Import order matters \u2014 this file is loaded AFTER @middag-io/react/style.css
445
453
  * so overrides here take precedence.
454
+ *
455
+ * \u2500\u2500 Built-in themes (PRO) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
456
+ * PRO users: 4 built-in themes are imported in main.tsx:
457
+ * classic (Maia) \u2014 default, warm neutral, shadcn/ui feel
458
+ * enterprise \u2014 Jira/Linear, dense, corporate blue
459
+ * soft \u2014 Notion/Craft, friendly, rose/pink
460
+ * midnight \u2014 GitHub Dark/Vercel, dev-tools
461
+ *
462
+ * Switch via: document.body.classList.add("theme-enterprise")
463
+ * See: https://ui-docs.middag.io/guides/theme#built-in-themes
464
+ *
465
+ * \u2500\u2500 Custom themes (all users) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
466
+ * Override :root for global changes, or create a .theme-{name} class
467
+ * for a switchable theme. Both Community and PRO users can create
468
+ * unlimited custom themes.
469
+ * See: https://ui-docs.middag.io/guides/theme#custom-themes
446
470
  */
447
471
 
448
472
  /* \u2500\u2500 Global token overrides \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -461,13 +485,13 @@ export type { PageContract, BlockDescriptor, SharedProps } from "@middag-io/reac
461
485
  }
462
486
  */
463
487
 
464
- /* \u2500\u2500 Example theme: Ocean \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
488
+ /* \u2500\u2500 Example custom theme: Ocean \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
465
489
  *
466
- * A complete theme override scoped to a CSS class.
467
- * Apply with: <div id="root" class="middag-root theme-ocean">
490
+ * A complete custom theme scoped to a CSS class.
491
+ * Apply with: document.body.classList.add("theme-ocean")
468
492
  *
469
- * This is how MIDDAG themes work \u2014 redefine tokens in a scope.
470
- * The scoped class overrides :root values for everything inside it.
493
+ * Override colors, radius, density, shadows, and layout in one class.
494
+ * Dark mode: use :root.dark .theme-ocean for dark overrides.
471
495
  */
472
496
 
473
497
  .theme-ocean {
@@ -483,6 +507,13 @@ export type { PageContract, BlockDescriptor, SharedProps } from "@middag-io/reac
483
507
  --info: oklch(0.55 0.14 200);
484
508
  --info-foreground: oklch(0.98 0 0);
485
509
 
510
+ /* Shape \u2014 tighter corners, denser rows */
511
+ --radius: 0.5rem;
512
+ --radius-sm: 4px;
513
+ --radius-lg: 8px;
514
+ --size-table-row: 44px;
515
+ --size-button-md: 34px;
516
+
486
517
  /* Sidebar */
487
518
  --sidebar: oklch(0.15 0.03 230);
488
519
  --sidebar-foreground: oklch(0.75 0.02 230);
@@ -494,6 +525,16 @@ export type { PageContract, BlockDescriptor, SharedProps } from "@middag-io/reac
494
525
  --sidebar-border: oklch(0.25 0.03 230);
495
526
  }
496
527
 
528
+ /* Dark mode for custom theme */
529
+ :root.dark .theme-ocean,
530
+ [data-theme="dark"] .theme-ocean {
531
+ --primary: oklch(0.65 0.14 230);
532
+ --primary-foreground: oklch(0.1 0.01 230);
533
+ --background: oklch(0.12 0.01 230);
534
+ --sidebar: oklch(0.10 0.02 230);
535
+ --sidebar-foreground: oklch(0.70 0.01 230);
536
+ }
537
+
497
538
  /* \u2500\u2500 Custom project styles \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
498
539
  *
499
540
  * Add project-specific styles below. These can target MIDDAG components
@@ -507,17 +548,9 @@ export type { PageContract, BlockDescriptor, SharedProps } from "@middag-io/reac
507
548
  * Radius: var(--radius-sm|md|lg|xl|2xl|full)
508
549
  * Shadows: var(--shadow-xs|sm|md|lg|xl|2xl)
509
550
  * Motion: var(--duration-fast|normal|moderate|slow)
551
+ * Sizing: var(--size-button-sm|md|lg), var(--size-table-row),
552
+ * var(--size-input), var(--sidebar-width)
510
553
  */
511
-
512
- /* Example: branded page header */
513
- /*
514
- .my-page-header {
515
- background: linear-gradient(135deg, var(--primary) 0%, var(--info) 100%);
516
- color: var(--primary-foreground);
517
- padding: var(--space-6) var(--space-8);
518
- border-radius: var(--radius-lg);
519
- }
520
- */
521
554
  `,
522
555
  "src/theme.css",
523
556
  );
@@ -635,7 +668,7 @@ export const dashboardContract: PageContract = {
635
668
  `/**
636
669
  * Connectors page contract \u2014 INTERMEDIATE example.
637
670
  *
638
- * Demonstrates the "split" layout with three block types:
671
+ * Demonstrates the "sidebar" layout with three block types:
639
672
  * - card_grid (connector cards in the main region)
640
673
  * - status_strip (health indicators in the aside)
641
674
  * - detail_panel (metadata in the aside)
@@ -659,7 +692,7 @@ export const connectorsContract: PageContract = {
659
692
  ],
660
693
  },
661
694
  layout: {
662
- template: "split",
695
+ template: "sidebar",
663
696
  regions: {
664
697
  main: [
665
698
  {
@@ -1266,7 +1299,8 @@ export function scaffoldHostEntry(targetDir, hostKey) {
1266
1299
  const label = `src/entry-${hostKey}.tsx`;
1267
1300
  if (skipIfExists(filePath, label)) return;
1268
1301
 
1269
- // Host-specific setup and post-mount code
1302
+ // Host-specific imports, setup, and post-mount code
1303
+ let extraImports = "";
1270
1304
  let setupCode = "";
1271
1305
  let postMountCode = "";
1272
1306
 
@@ -1291,7 +1325,20 @@ export function scaffoldHostEntry(targetDir, hostKey) {
1291
1325
  });
1292
1326
  observer.observe(document.body, { childList: true, subtree: true });`;
1293
1327
  } else if (hostKey === "moodle") {
1294
- setupCode = ` document.body.classList.add("middag-active");`;
1328
+ extraImports = `import "./tailwind.css";
1329
+ import "@fontsource-variable/figtree";`;
1330
+ setupCode = ` document.body.classList.add("middag-active");
1331
+
1332
+ // Portal container for Radix UI (modals, popovers, toasts).
1333
+ // Radix portals default to document.body which is outside .middag-root,
1334
+ // meaning scoped Tailwind styles won't apply. This creates a sibling
1335
+ // container with .middag-root so portal content inherits design tokens.
1336
+ if (!document.getElementById("middag-portals")) {
1337
+ const portalContainer = document.createElement("div");
1338
+ portalContainer.id = "middag-portals";
1339
+ portalContainer.classList.add("middag-root");
1340
+ document.body.appendChild(portalContainer);
1341
+ }`;
1295
1342
  }
1296
1343
 
1297
1344
  const content = `/**
@@ -1308,7 +1355,7 @@ import { createInertiaApp } from "@inertiajs/react";
1308
1355
  import { I18nProvider, ProgressProvider } from "@middag-io/react";
1309
1356
  import "@middag-io/react/style.css";
1310
1357
  import "./theme.css";
1311
- import { registerDefaults } from "./app/register";
1358
+ ${ extraImports }import { registerDefaults } from "./app/register";
1312
1359
  import { resolvePageComponent } from "./app/page-resolver";
1313
1360
 
1314
1361
  registerDefaults();
@@ -1365,17 +1412,71 @@ export function scaffoldHostViteConfig(targetDir, hostKey, host) {
1365
1412
  },
1366
1413
  },`;
1367
1414
  } else if (hostKey === "moodle") {
1368
- outDir = `resolve(__dirname, "../amd/build")`;
1369
- formats = `["iife"]`;
1370
- libName = `"MiddagUI"`;
1371
- fileName = `() => "app.js"`;
1372
- extraRollup = `
1415
+ // Moodle uses AMD format with vite-plugin-moodle-amd.
1416
+ // Generate a dedicated AMD config instead of the shared lib template.
1417
+ const content = `/**
1418
+ * Vite build config for Moodle — AMD production build.
1419
+ *
1420
+ * Usage:
1421
+ * npm run build:moodle \u2192 AMD chunks to dist/ + amd/
1422
+ * npm run watch:moodle \u2192 rebuild on change
1423
+ *
1424
+ * Output: AMD modules compatible with Moodle's RequireJS loader.
1425
+ * vite-plugin-moodle-amd rewrites chunk paths and copies to amd/.
1426
+ * CSS is copied to styles/middag-app.css (Moodle auto-discovers it).
1427
+ *
1428
+ * The dev server (\`npm run dev\`) uses vite.config.ts instead.
1429
+ */
1430
+ import { defineConfig } from "vite";
1431
+ import react from "@vitejs/plugin-react";
1432
+ import tailwindcss from "@tailwindcss/vite";
1433
+ import moodleAmd from "./plugins/vite-plugin-moodle-amd";
1434
+ import { resolve } from "path";
1435
+
1436
+ export default defineConfig({
1437
+ plugins: [react(), tailwindcss(), moodleAmd()],
1438
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
1439
+ resolve: { alias: { "@/": resolve(__dirname, "src") + "/" } },
1440
+ build: {
1441
+ outDir: resolve(__dirname, "dist"),
1442
+ emptyOutDir: true,
1443
+ minify: "esbuild",
1444
+ cssCodeSplit: false,
1445
+ rollupOptions: {
1446
+ input: { "middag-app": resolve(__dirname, "src/entry-moodle.tsx") },
1447
+ external: ["core/ajax", "core/str", "core/notification", "jquery"],
1373
1448
  output: {
1374
- assetFileNames: (assetInfo) => {
1375
- if (assetInfo.name?.endsWith(".css")) return "style.css";
1376
- return assetInfo.name || "[name]-[hash][extname]";
1449
+ format: "amd",
1450
+ dir: resolve(__dirname, "dist"),
1451
+ entryFileNames: "[name].js",
1452
+ chunkFileNames: "[name].js",
1453
+ manualChunks(id) {
1454
+ if (
1455
+ id.includes("node_modules/react/") ||
1456
+ id.includes("node_modules/react-dom/") ||
1457
+ id.includes("node_modules/scheduler/") ||
1458
+ id.includes("node_modules/@inertiajs/")
1459
+ ) {
1460
+ return "react-vendor-lazy";
1461
+ }
1462
+ if (
1463
+ id.includes("node_modules/@radix-ui/") ||
1464
+ id.includes("node_modules/@base-ui/") ||
1465
+ id.includes("node_modules/class-variance-authority/")
1466
+ ) {
1467
+ return "react-ui-lazy";
1468
+ }
1469
+ if (id.includes("node_modules/@tanstack/")) {
1470
+ return "react-table-lazy";
1471
+ }
1377
1472
  },
1378
- },`;
1473
+ },
1474
+ },
1475
+ },
1476
+ });
1477
+ `;
1478
+ writeFile(filePath, content, label);
1479
+ return;
1379
1480
  } else {
1380
1481
  outDir = `resolve(__dirname, "../dist")`;
1381
1482
  formats = `["es"]`;
@@ -1498,8 +1599,18 @@ body.middag-active {
1498
1599
  } else if (hostKey === "moodle") {
1499
1600
  hostSection = `
1500
1601
 
1501
- /* \u2500\u2500 Moodle Boost integration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1502
- * Active when MIDDAG mounts inside Moodle admin (body.middag-active).
1602
+ /* ── Moodle Boost integration ────────────────────────────────────────────
1603
+ * Active when MIDDAG mounts inside Moodle (body.middag-active).
1604
+ *
1605
+ * CSS Isolation Strategy:
1606
+ *
1607
+ * OUTWARD (MIDDAG → Moodle):
1608
+ * Tailwind utility classes only match elements that carry them.
1609
+ * Known collisions with Bootstrap are neutralized below.
1610
+ *
1611
+ * INWARD (Moodle → MIDDAG):
1612
+ * .middag-root blocks inherited Moodle/Bootstrap styles.
1613
+ * Portals render inside #middag-portals (also .middag-root).
1503
1614
  */
1504
1615
 
1505
1616
  body.middag-active {
@@ -1508,7 +1619,31 @@ body.middag-active {
1508
1619
 
1509
1620
  body.middag-active [data-slot="sidebar-container"] {
1510
1621
  left: 0 !important;
1511
- }`;
1622
+ }
1623
+
1624
+ /* ── Outward isolation: Tailwind ↔ Bootstrap collision fixes ─────────────
1625
+ *
1626
+ * .collapse — Bootstrap uses display:none/block for collapsible fieldsets.
1627
+ * Tailwind emits \`.collapse { visibility: collapse }\` which breaks them.
1628
+ * Neutralize outside the React tree (.middag-root).
1629
+ *
1630
+ * Add new entries here as collisions are discovered.
1631
+ */
1632
+ .collapse:not(.middag-root .collapse, .middag-root.collapse) {
1633
+ visibility: visible !important;
1634
+ }
1635
+
1636
+ /* ── Theme bridge: inherit Moodle Boost primary color ────────────────────
1637
+ *
1638
+ * If Boost defines --bs-primary, map it to --middag-brand so the
1639
+ * MIDDAG UI inherits the institution's brand color automatically.
1640
+ * Uncomment and adjust if your Moodle theme uses a different variable.
1641
+ */
1642
+ /*
1643
+ :root {
1644
+ --middag-brand: var(--bs-primary);
1645
+ }
1646
+ */`;
1512
1647
  } else {
1513
1648
  hostSection = `
1514
1649
 
@@ -1817,7 +1952,12 @@ export function scaffoldPageResolver(targetDir) {
1817
1952
  *
1818
1953
  * @param {string} hostKey - 'wordpress' | 'moodle' | 'custom'
1819
1954
  */
1820
- export function scaffoldRouteHelper(targetDir, hostKey) {
1955
+ /**
1956
+ * @param {string} targetDir
1957
+ * @param {string} hostKey - 'wordpress' | 'moodle' | 'custom'
1958
+ * @param {string} [pluginSlug] - For Moodle: slug portion of frankenstyle (e.g. "middag" from "local_middag")
1959
+ */
1960
+ export function scaffoldRouteHelper(targetDir, hostKey, pluginSlug) {
1821
1961
  ensureDir(join(targetDir, "src", "lib"));
1822
1962
  const filePath = join(targetDir, "src", "lib", "routes.ts");
1823
1963
  if (skipIfExists(filePath, "src/lib/routes.ts")) return;
@@ -1830,7 +1970,13 @@ export function scaffoldRouteHelper(targetDir, hostKey) {
1830
1970
  const template = templateMap[hostKey] || templateMap.wordpress;
1831
1971
 
1832
1972
  try {
1833
- writeFile(filePath, readTemplate(template), "src/lib/routes.ts");
1973
+ let content = readTemplate(template);
1974
+ if (hostKey === "moodle" && pluginSlug) {
1975
+ // Convert frankenstyle slug to Moodle path: "middag" with type prefix "local" → "local/middag"
1976
+ // pluginSlug is the name part; the full frankenstyle comes from cli.js
1977
+ content = content.replace(/__PLUGIN_PATH__/g, pluginSlug);
1978
+ }
1979
+ writeFile(filePath, content, "src/lib/routes.ts");
1834
1980
  } catch {
1835
1981
  // Fallback to WP template if host-specific one doesn't exist
1836
1982
  writeFile(filePath, readTemplate("templates/shared/route-helper-wp.ts"), "src/lib/routes.ts");
@@ -1847,3 +1993,58 @@ export function scaffoldDemoDirectPage(targetDir) {
1847
1993
  if (skipIfExists(filePath, "src/pages/DemoPage.tsx")) return;
1848
1994
  writeFile(filePath, readTemplate("templates/shared/demo-page.tsx"), "src/pages/DemoPage.tsx");
1849
1995
  }
1996
+
1997
+ // ── Moodle-specific scaffold functions ────────────────────────────────────
1998
+
1999
+ /**
2000
+ * Scaffold vite-plugin-moodle-amd: plugins/vite-plugin-moodle-amd.ts.
2001
+ * Converts Rollup AMD output to Moodle's RequireJS format and copies
2002
+ * built files to amd/src/ + amd/build/ + styles/.
2003
+ *
2004
+ * @param {string} targetDir - Absolute path to UI dir
2005
+ * @param {string} pluginPrefix - Moodle frankenstyle (e.g. "local_middag")
2006
+ */
2007
+ export function scaffoldMoodlePlugin(targetDir, pluginPrefix) {
2008
+ ensureDir(join(targetDir, "plugins"));
2009
+ const filePath = join(targetDir, "plugins", "vite-plugin-moodle-amd.ts");
2010
+ if (skipIfExists(filePath, "plugins/vite-plugin-moodle-amd.ts")) return;
2011
+ const content = readTemplate("templates/shared/vite-plugin-moodle-amd.ts")
2012
+ .replace(/__PLUGIN_PREFIX__/g, pluginPrefix);
2013
+ writeFile(filePath, content, "plugins/vite-plugin-moodle-amd.ts");
2014
+ }
2015
+
2016
+ /**
2017
+ * Scaffold Tailwind CSS entry for Moodle AMD build: src/tailwind.css.
2018
+ * Brings in the Tailwind engine so local pages get their utilities compiled.
2019
+ */
2020
+ export function scaffoldMoodleTailwind(targetDir) {
2021
+ ensureDir(join(targetDir, "src"));
2022
+ const filePath = join(targetDir, "src", "tailwind.css");
2023
+ if (skipIfExists(filePath, "src/tailwind.css")) return;
2024
+ writeFile(filePath, readTemplate("templates/shared/tailwind.css"), "src/tailwind.css");
2025
+ }
2026
+
2027
+ /**
2028
+ * Scaffold Moodle AMD adapters: src/lib/moodle/.
2029
+ * Typed wrappers for core/ajax, core/str, core/notification.
2030
+ *
2031
+ * @param {string} targetDir - Absolute path to UI dir
2032
+ * @param {string} pluginPrefix - Moodle frankenstyle (e.g. "local_middag")
2033
+ */
2034
+ export function scaffoldMoodleAdapters(targetDir, pluginPrefix) {
2035
+ ensureDir(join(targetDir, "src", "lib", "moodle"));
2036
+
2037
+ const files = [
2038
+ { template: "templates/shared/moodle-ajax.ts", dest: "src/lib/moodle/ajax.ts" },
2039
+ { template: "templates/shared/moodle-strings.ts", dest: "src/lib/moodle/strings.ts" },
2040
+ { template: "templates/shared/moodle-notification.ts", dest: "src/lib/moodle/notification.ts" },
2041
+ ];
2042
+
2043
+ for (const { template, dest } of files) {
2044
+ const filePath = join(targetDir, dest);
2045
+ if (skipIfExists(filePath, dest)) continue;
2046
+ const content = readTemplate(template)
2047
+ .replace(/__PLUGIN_PREFIX__/g, pluginPrefix);
2048
+ writeFile(filePath, content, dest);
2049
+ }
2050
+ }
@@ -6,13 +6,13 @@
6
6
  * mock/navigation.ts — sidebar structure
7
7
  * mock/data.ts — synthetic page props
8
8
  */
9
- import { useEffect } from "react";
10
- import { BrowserRouter, Routes, useNavigate } from "react-router";
11
- import { I18nProvider } from "@middag-io/react";
12
- import { MockPageProvider, MockI18nProvider, setMockNavigate } from "@middag-io/react/mock";
13
- import { buildNavigation } from "../mock/navigation";
14
- import { sharedProps } from "../mock/data";
15
- import { AppRoutes } from "../mock/routes";
9
+ import {useEffect} from "react";
10
+ import {BrowserRouter, Routes, useNavigate} from "react-router";
11
+ import {I18nProvider} from "@middag-io/react";
12
+ import {MockI18nProvider, MockPageProvider, setMockNavigate} from "@middag-io/react/mock";
13
+ import {buildNavigation} from "../mock/navigation";
14
+ import {sharedProps} from "../mock/data";
15
+ import {AppRoutes} from "../mock/routes";
16
16
 
17
17
  let _navigate: ((to: string) => void) | null = null;
18
18
  function NavigateBridge() {
@@ -24,7 +24,7 @@ function NavigateBridge() {
24
24
  return null;
25
25
  }
26
26
  if (typeof window !== "undefined") {
27
- (window as any).__MIDDAG_MOCK_NAVIGATE__ = (to: string) => {
27
+ (window as Record<string, unknown>).__MIDDAG_MOCK_NAVIGATE__ = (to: string) => {
28
28
  if (_navigate) _navigate(to);
29
29
  };
30
30
  }
@@ -1,13 +1,18 @@
1
1
  import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
+ import { registerDefaults, registerShell } from "@middag-io/react";
3
4
  import { MockProductShell } from "@middag-io/react/mock";
4
- import { registerShell } from "@middag-io/react";
5
5
  import "@middag-io/react/style.css";
6
+ import "@middag-io/react/themes/classic.css";
7
+ import "@middag-io/react/themes/enterprise.css";
8
+ import "@middag-io/react/themes/soft.css";
9
+ import "@middag-io/react/themes/midnight.css";
6
10
  import "./theme.css";
7
11
  import "@fontsource-variable/figtree";
8
- import { registerDefaults } from "./app/register";
9
12
  import { App } from "./app";
10
13
 
14
+ // Dev mode: register all blocks from the lib (19 blocks + fields + icons + cells).
15
+ // In production, entry-*.tsx uses the selective register from ./app/register.
11
16
  registerDefaults();
12
17
  registerShell("product", MockProductShell);
13
18
 
@@ -2,8 +2,14 @@
2
2
  * register — selective registration for this plugin's UI (PRO).
3
3
  *
4
4
  * Registers shells, layouts, and blocks this plugin uses.
5
+ * All 13 standard blocks (exported from the barrel) are included.
5
6
  * Add or remove registrations as your pages need them.
6
7
  *
8
+ * Heavy lazy-loaded blocks (chart_panel, kanban_board, flow_editor,
9
+ * form_builder, condition_tree, sentence_builder) are NOT included here.
10
+ * To use them, import via deep path and registerBlock() individually,
11
+ * or call registerDefaults() from @middag-io/react instead.
12
+ *
7
13
  * Full catalog: https://docs.middag.io/blocks
8
14
  */
9
15
 
@@ -12,12 +18,14 @@ import {
12
18
  registerLayout,
13
19
  registerBlock,
14
20
  // Shells
15
- HostProductShell,
21
+ ProductShell,
22
+ ImmersiveShell,
16
23
  // Layouts
17
24
  StackLayout,
18
- SplitLayout,
25
+ SidebarLayout,
19
26
  DashboardLayout,
20
- // Blocks
27
+ WizardLayout,
28
+ // Blocks (all 13 standard barrel exports)
21
29
  DenseTableBlock,
22
30
  MetricCardBlock,
23
31
  EmptyStateBlock,
@@ -27,6 +35,10 @@ import {
27
35
  TabbedPanelBlock,
28
36
  ActivityTimelineBlock,
29
37
  WorkflowProgressBlock,
38
+ MarkdownPanelBlock,
39
+ CardGridBlock,
40
+ ActionGridBlock,
41
+ LinkListBlock,
30
42
  } from "@middag-io/react";
31
43
 
32
44
  let registered = false;
@@ -36,14 +48,16 @@ export function registerDefaults(): void {
36
48
  registered = true;
37
49
 
38
50
  // Shells
39
- registerShell("product", HostProductShell);
51
+ registerShell("product", ProductShell);
52
+ registerShell("immersive", ImmersiveShell);
40
53
 
41
54
  // Layouts
42
55
  registerLayout("stack", StackLayout);
43
- registerLayout("split", SplitLayout);
56
+ registerLayout("sidebar", SidebarLayout);
44
57
  registerLayout("dashboard", DashboardLayout);
58
+ registerLayout("wizard", WizardLayout);
45
59
 
46
- // Blocks — add more as your pages need them
60
+ // Blocks — all 13 standard blocks from the barrel
47
61
  // See: https://docs.middag.io/blocks for the full catalog
48
62
  registerBlock("dense_table", DenseTableBlock);
49
63
  registerBlock("metric_card", MetricCardBlock);
@@ -54,4 +68,8 @@ export function registerDefaults(): void {
54
68
  registerBlock("tabbed_panel", TabbedPanelBlock);
55
69
  registerBlock("activity_timeline", ActivityTimelineBlock);
56
70
  registerBlock("workflow_progress", WorkflowProgressBlock);
71
+ registerBlock("markdown_panel", MarkdownPanelBlock);
72
+ registerBlock("card_grid", CardGridBlock);
73
+ registerBlock("action_grid", ActionGridBlock);
74
+ registerBlock("link_list", LinkListBlock);
57
75
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Typed adapter for Moodle's core/ajax AMD module.
3
+ *
4
+ * Wraps Ajax.call() with generics for type-safe web service calls.
5
+ * React components use this instead of importing core/ajax directly.
6
+ */
7
+
8
+ // @ts-expect-error AMD external — resolved by Moodle's RequireJS at runtime
9
+ import Ajax from "core/ajax";
10
+
11
+ export interface WebServiceCall {
12
+ methodname: string;
13
+ args: Record<string, unknown>;
14
+ }
15
+
16
+ /**
17
+ * Call a single Moodle web service and return the typed result.
18
+ */
19
+ export async function callWebService<T = unknown>(
20
+ call: WebServiceCall,
21
+ ): Promise<T> {
22
+ const [result] = await Ajax.call([call]);
23
+ return result as T;
24
+ }
25
+
26
+ /**
27
+ * Call multiple Moodle web services in a single request.
28
+ */
29
+ export async function callWebServices(
30
+ calls: WebServiceCall[],
31
+ ): Promise<unknown[]> {
32
+ return Ajax.call(calls);
33
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Typed adapter for Moodle's core/notification AMD module.
3
+ *
4
+ * Centralises error reporting so React components never import
5
+ * Moodle AMD externals directly.
6
+ */
7
+
8
+ // @ts-expect-error AMD external — resolved by Moodle's RequireJS at runtime
9
+ import Notification from "core/notification";
10
+
11
+ /**
12
+ * Report an exception to Moodle's notification system.
13
+ */
14
+ export function reportException(error: Error): void {
15
+ Notification.exception(error);
16
+ }
17
+
18
+ /**
19
+ * Show an alert dialog via Moodle's notification system.
20
+ */
21
+ export function alert(title: string, message: string): void {
22
+ Notification.alert(title, message);
23
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Typed adapter for Moodle's core/str AMD module.
3
+ *
4
+ * Wraps the native `get_strings` function with TypeScript types
5
+ * and returns a key→value Record for convenient consumption.
6
+ *
7
+ * Can be used as the asyncResolver for I18nProvider:
8
+ *
9
+ * import { getString } from "@/lib/moodle/strings";
10
+ * <I18nProvider asyncResolver={getString}>
11
+ *
12
+ * Change the default component below to match your plugin's frankenstyle.
13
+ */
14
+
15
+ // @ts-expect-error AMD external — resolved by Moodle's RequireJS at runtime
16
+ import {get_strings} from "core/str";
17
+
18
+ export interface StringRequest {
19
+ key: string;
20
+ component?: string;
21
+ param?: string | number;
22
+ }
23
+
24
+ // ── CHANGE THIS to your plugin's frankenstyle name ──
25
+ const DEFAULT_COMPONENT = "__PLUGIN_PREFIX__";
26
+
27
+ /**
28
+ * Load translated strings from Moodle's string manager.
29
+ *
30
+ * @param requests - Array of string requests (key + optional component/param)
31
+ * @returns Record mapping each key to its translated string
32
+ */
33
+ export async function getStrings(
34
+ requests: StringRequest[],
35
+ ): Promise<Record<string, string>> {
36
+ const moodleRequests = requests.map((r) => ({
37
+ key: r.key,
38
+ component: r.component ?? DEFAULT_COMPONENT,
39
+ param: r.param,
40
+ }));
41
+
42
+ const results: string[] = await get_strings(moodleRequests);
43
+
44
+ const out: Record<string, string> = {};
45
+ results.forEach((translated, index) => {
46
+ out[requests[index].key] = translated;
47
+ });
48
+
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Load a single translated string.
54
+ * Compatible with I18nProvider's AsyncStringResolver signature.
55
+ */
56
+ export async function getString(
57
+ key: string,
58
+ component = DEFAULT_COMPONENT,
59
+ ): Promise<string> {
60
+ const result = await getStrings([{key, component}]);
61
+ return result[key] ?? key;
62
+ }
@@ -1,6 +1,6 @@
1
- import { ContractPage } from "@middag-io/react";
2
- import { usePage } from "@inertiajs/react";
3
- import type { PageContract, ContractPageProps } from "@middag-io/react";
1
+ import type {ContractPageProps, PageContract} from "@middag-io/react";
2
+ import {ContractPage} from "@middag-io/react";
3
+ import {usePage} from "@inertiajs/react";
4
4
 
5
5
  // Direct pages — eager loaded (custom React pages in pages/)
6
6
  const directPages = import.meta.glob("../pages/**/*.tsx", { eager: true }) as Record<
@@ -12,6 +12,7 @@ const directPages = import.meta.glob("../pages/**/*.tsx", { eager: true }) as Re
12
12
  * Fallback component — reads PageContract from Inertia props and renders
13
13
  * it via the lib's ContractPage. Used when no direct page matches.
14
14
  */
15
+ // eslint-disable-next-line react-refresh/only-export-components
15
16
  function InertiaContractPage() {
16
17
  const { props } = usePage<{
17
18
  contract: PageContract;
@@ -16,17 +16,23 @@ import {
16
16
  registerLayout,
17
17
  registerBlock,
18
18
  // Shells
19
- HostProductShell,
19
+ ProductShell,
20
+ ImmersiveShell,
20
21
  // Layouts
21
22
  StackLayout,
22
- SplitLayout,
23
+ SidebarLayout,
23
24
  DashboardLayout,
25
+ WizardLayout,
24
26
  // Blocks
25
27
  DenseTableBlock,
26
28
  MetricCardBlock,
27
29
  EmptyStateBlock,
28
30
  DetailPanelBlock,
29
31
  FormPanelBlock,
32
+ CardGridBlock,
33
+ StatusStripBlock,
34
+ TabbedPanelBlock,
35
+ LinkListBlock,
30
36
  } from "@middag-io/react";
31
37
 
32
38
  let registered = false;
@@ -36,12 +42,14 @@ export function registerDefaults(): void {
36
42
  registered = true;
37
43
 
38
44
  // Shells
39
- registerShell("product", HostProductShell);
45
+ registerShell("product", ProductShell);
46
+ registerShell("immersive", ImmersiveShell);
40
47
 
41
48
  // Layouts
42
49
  registerLayout("stack", StackLayout);
43
- registerLayout("split", SplitLayout);
50
+ registerLayout("sidebar", SidebarLayout);
44
51
  registerLayout("dashboard", DashboardLayout);
52
+ registerLayout("wizard", WizardLayout);
45
53
 
46
54
  // Blocks — add more as your pages need them
47
55
  registerBlock("dense_table", DenseTableBlock);
@@ -49,4 +57,8 @@ export function registerDefaults(): void {
49
57
  registerBlock("empty_state", EmptyStateBlock);
50
58
  registerBlock("detail_panel", DetailPanelBlock);
51
59
  registerBlock("form_panel", FormPanelBlock);
60
+ registerBlock("card_grid", CardGridBlock);
61
+ registerBlock("status_strip", StatusStripBlock);
62
+ registerBlock("tabbed_panel", TabbedPanelBlock);
63
+ registerBlock("link_list", LinkListBlock);
52
64
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Route helper — custom host URL builder.
3
+ *
4
+ * Abstracts away the difference between:
5
+ * - Dev server (mock navigate via react-router)
6
+ * - Production (your host platform's URL scheme)
7
+ *
8
+ * Adjust the production URL pattern below to match your backend routing.
9
+ */
10
+
11
+ declare global {
12
+ interface Window {
13
+ __MIDDAG_MOCK_NAVIGATE__?: (to: string) => void;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Build a URL for a given path.
19
+ *
20
+ * @param path - Route path (e.g. "/connectors", "/settings/general")
21
+ * @param params - Optional query parameters
22
+ * @returns Full URL string
23
+ *
24
+ * @example
25
+ * route("/connectors")
26
+ * // dev → navigates via react-router
27
+ * // prod → "/app/connectors"
28
+ *
29
+ * route("/settings", { tab: "general" })
30
+ * // prod → "/app/settings?tab=general"
31
+ */
32
+ export function route(path: string, params?: Record<string, string>): string {
33
+ // Dev mode — mock navigate (react-router)
34
+ if (typeof window !== "undefined" && window.__MIDDAG_MOCK_NAVIGATE__) {
35
+ const url = path + (params ? "?" + new URLSearchParams(params).toString() : "");
36
+ window.__MIDDAG_MOCK_NAVIGATE__(url);
37
+ return url;
38
+ }
39
+
40
+ // Production — adjust base path to match your backend routing
41
+ const base = "/app";
42
+ const query = params ? "?" + new URLSearchParams(params).toString() : "";
43
+ return `${base}${path}${query}`;
44
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Route helper — generates correct URLs for Moodle production vs dev mock.
3
+ *
4
+ * Moodle routing modes:
5
+ *
6
+ * Classic (Moodle ≤ 5.0):
7
+ * /local/{plugin}/index.php/{path}
8
+ *
9
+ * New router (Moodle 5.1+, opt-in):
10
+ * /r.php/{frankenstyle}/{path}
11
+ *
12
+ * Dev mock (BrowserRouter):
13
+ * /{path} directly
14
+ *
15
+ * In production, most navigation URLs come from PHP via Inertia shared props
16
+ * (navigation payload, PageContract actions). Use this helper only when
17
+ * the React side needs to build a URL programmatically (e.g. router.visit).
18
+ *
19
+ * Detection: window.__MIDDAG_MOCK_NAVIGATE__ → dev mock
20
+ * window.__MIDDAG_ROUTE_BASE__ → production base (set by PHP)
21
+ */
22
+
23
+ /** Base path set by the Moodle bootstrap. Falls back to classic format. */
24
+ const CLASSIC_BASE = "/__PLUGIN_PATH__/index.php";
25
+
26
+ function getBase(): string {
27
+ if (typeof window !== "undefined") {
28
+ // Dev mock — no base prefix
29
+ if ("__MIDDAG_MOCK_NAVIGATE__" in window) return "";
30
+
31
+ // Production — PHP sets the base in the bootstrap HTML
32
+ // Supports both classic (/local/plugin/index.php) and r.php (/r.php/frankenstyle)
33
+ const custom = (window as Record<string, unknown>).__MIDDAG_ROUTE_BASE__;
34
+ if (typeof custom === "string") return custom;
35
+ }
36
+
37
+ return CLASSIC_BASE;
38
+ }
39
+
40
+ /**
41
+ * Build a URL for a route within this Moodle plugin.
42
+ *
43
+ * @param path - Route path (e.g. "/admin/dashboard", "/connectors/1")
44
+ * @param params - Optional URL search params (e.g. { courseid: "5" })
45
+ *
46
+ * @example
47
+ * route("/admin/dashboard")
48
+ * // dev: /admin/dashboard
49
+ * // prod: /local/myplugin/index.php/admin/dashboard
50
+ * // 5.1: /r.php/local_myplugin/admin/dashboard
51
+ *
52
+ * route("/admin/logs", { level: "error" })
53
+ * // → .../admin/logs?level=error
54
+ */
55
+ export function route(
56
+ path: string,
57
+ params?: Record<string, string>,
58
+ ): string {
59
+ const base = getBase();
60
+ const url = `${base}${path}`;
61
+
62
+ if (!params) return url;
63
+
64
+ const search = new URLSearchParams(params).toString();
65
+ return `${url}?${search}`;
66
+ }
@@ -1,26 +1,58 @@
1
1
  /**
2
2
  * Route helper — generates correct URLs for WordPress production vs dev mock.
3
3
  *
4
- * In production (Inertia): /wp-admin/admin.php?page=middag-{slug}
5
- * In dev mock (BrowserRouter): /{slug} directly
4
+ * Production (Inertia):
5
+ * /wp-admin/admin.php?page=middag-{slug}
6
+ * /wp-admin/admin.php?page=middag-{slug}&route={path}
6
7
  *
7
- * Detection: if window.__MIDDAG_MOCK_NAVIGATE__ exists, we're in dev mock.
8
+ * Dev mock (BrowserRouter):
9
+ * /{path} directly
10
+ *
11
+ * Detection: window.__MIDDAG_MOCK_NAVIGATE__ → dev mock
8
12
  */
9
13
 
14
+ function isMock(): boolean {
15
+ return typeof window !== "undefined" && "__MIDDAG_MOCK_NAVIGATE__" in window;
16
+ }
17
+
10
18
  /**
11
19
  * Build a URL for a MIDDAG admin page.
12
20
  *
13
- * Detection is lazy (checked per call) because module evaluation order
14
- * means the mock flag may not be set when this module first loads.
15
- *
16
21
  * @param slug - Domain slug (e.g. "entitlements", "organizations")
17
- * @param path - Optional sub-path (e.g. "/entitlements/1/edit")
22
+ * @param path - Optional sub-path for router dispatch (e.g. "/entitlements/1/edit")
23
+ * @param params - Optional extra query params (e.g. { tab: "billing" })
24
+ *
25
+ * @example
26
+ * route("dashboard")
27
+ * // dev: /dashboard
28
+ * // prod: /wp-admin/admin.php?page=middag-dashboard
29
+ *
30
+ * route("entitlements", "/entitlements/1/edit")
31
+ * // dev: /entitlements/1/edit
32
+ * // prod: /wp-admin/admin.php?page=middag-entitlements&route=/entitlements/1/edit
33
+ *
34
+ * route("entitlements", "/entitlements/1", { tab: "billing" })
35
+ * // dev: /entitlements/1?tab=billing
36
+ * // prod: /wp-admin/admin.php?page=middag-entitlements&route=/entitlements/1&tab=billing
18
37
  */
19
- export function route(slug: string, path?: string): string {
20
- if (typeof window !== "undefined" && "__MIDDAG_MOCK_NAVIGATE__" in window) {
21
- return path ?? `/${slug}`;
38
+ export function route(
39
+ slug: string,
40
+ path?: string,
41
+ params?: Record<string, string>,
42
+ ): string {
43
+ if (isMock()) {
44
+ const url = path ?? `/${slug}`;
45
+ if (!params) return url;
46
+ return `${url}?${new URLSearchParams(params).toString()}`;
22
47
  }
23
48
 
24
49
  const base = `/wp-admin/admin.php?page=middag-${slug}`;
25
- return path ? `${base}&route=${path}` : base;
50
+ const parts = [base];
51
+ if (path) parts.push(`route=${encodeURIComponent(path)}`);
52
+ if (params) {
53
+ for (const [k, v] of Object.entries(params)) {
54
+ parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
55
+ }
56
+ }
57
+ return parts.join("&");
26
58
  }
@@ -0,0 +1,12 @@
1
+ /*
2
+ * Tailwind CSS entry — processed by @tailwindcss/vite during AMD build.
3
+ *
4
+ * This file brings in the Tailwind engine so that any utility classes
5
+ * used in your local pages (src/pages/) and components (src/blocks/,
6
+ * src/components/) are compiled into the final CSS.
7
+ *
8
+ * The lib's pre-compiled style.css covers all @middag-io/react components.
9
+ * This file covers YOUR custom code. Both are merged into a single
10
+ * styles/middag-app.css by the Moodle build.
11
+ */
12
+ @import "tailwindcss";
@@ -0,0 +1,122 @@
1
+ /**
2
+ * vite-plugin-moodle-amd — Rewrites Rollup AMD output for Moodle's RequireJS.
3
+ *
4
+ * Rollup's AMD format with code splitting uses relative paths between chunks
5
+ * (e.g. './react-vendor-lazy'). Moodle's RequireJS can resolve these relative
6
+ * to the plugin prefix, but only when the module namespace is correct.
7
+ *
8
+ * This plugin:
9
+ * 1. Rewrites relative inter-chunk paths to absolute `{PLUGIN_PREFIX}/{name}` format
10
+ * 2. Adds named define IDs for better debuggability
11
+ * 3. Copies built JS to amd/src/ (dev) and amd/build/ (prod .min.js)
12
+ * 4. Copies CSS to styles/ so Moodle auto-discovers it
13
+ *
14
+ * Change PLUGIN_PREFIX below to match your plugin's frankenstyle name
15
+ * (e.g. 'local_yourplugin', 'mod_yourmod').
16
+ */
17
+
18
+ import type {Plugin, ResolvedConfig} from "vite";
19
+ import {copyFileSync, existsSync, mkdirSync, readdirSync} from "node:fs";
20
+ import {basename, resolve} from "node:path";
21
+
22
+ // ── CHANGE THIS to your Moodle plugin's frankenstyle name ──
23
+ const PLUGIN_PREFIX = "__PLUGIN_PREFIX__";
24
+
25
+ export default function moodleAmd(): Plugin {
26
+ let config: ResolvedConfig;
27
+
28
+ return {
29
+ name: "vite-plugin-moodle-amd",
30
+ apply: "build",
31
+
32
+ configResolved(resolved) {
33
+ config = resolved;
34
+ },
35
+
36
+ /**
37
+ * Post-process each AMD chunk to rewrite paths.
38
+ *
39
+ * Rollup generates: define(['./react-vendor-lazy', ...], function(...) { ... })
40
+ * We rewrite to: define('PLUGIN_PREFIX/middag-app', ['PLUGIN_PREFIX/react-vendor-lazy', ...], function(...) { ... })
41
+ */
42
+ generateBundle(_, bundle) {
43
+ for (const [fileName, chunk] of Object.entries(bundle)) {
44
+ if (chunk.type !== "chunk") continue;
45
+
46
+ let code = chunk.code;
47
+ const moduleName = fileName.replace(/\.js$/, "");
48
+
49
+ // Rewrite relative AMD dependencies to absolute Moodle paths.
50
+ code = code.replace(
51
+ /define\(\[([\s\S]*?)\]/,
52
+ (match, deps: string) => {
53
+ const rewritten = deps.replace(
54
+ /(["'])\.\/([^"']+)\1/g,
55
+ `$1${PLUGIN_PREFIX}/$2$1`,
56
+ );
57
+ return `define([${rewritten}]`;
58
+ },
59
+ );
60
+
61
+ // Add named module ID.
62
+ if (!code.includes(`define('${PLUGIN_PREFIX}/${moduleName}'`)) {
63
+ code = code.replace(
64
+ /define\(\[/,
65
+ `define('${PLUGIN_PREFIX}/${moduleName}',[`,
66
+ );
67
+ }
68
+
69
+ chunk.code = code;
70
+ }
71
+ },
72
+
73
+ /**
74
+ * After each build, copy chunks to amd/src/ and amd/build/.
75
+ * Makes `npm run watch:moodle` auto-update both directories.
76
+ */
77
+ closeBundle() {
78
+ const outDir = config.build.outDir;
79
+ const pluginRoot = resolve(config.root, "..");
80
+ const amdSrc = resolve(pluginRoot, "amd/src");
81
+ const amdBuild = resolve(pluginRoot, "amd/build");
82
+
83
+ if (!existsSync(outDir)) return;
84
+ if (!existsSync(amdSrc)) mkdirSync(amdSrc, {recursive: true});
85
+ if (!existsSync(amdBuild)) mkdirSync(amdBuild, {recursive: true});
86
+
87
+ const jsFiles = readdirSync(outDir).filter((f) => f.endsWith(".js"));
88
+
89
+ for (const file of jsFiles) {
90
+ const src = resolve(outDir, file);
91
+ const name = basename(file, ".js");
92
+
93
+ copyFileSync(src, resolve(amdSrc, file));
94
+ copyFileSync(src, resolve(amdBuild, `${name}.min.js`));
95
+ }
96
+
97
+ if (jsFiles.length > 0) {
98
+ console.log(
99
+ `[moodle-amd] ${jsFiles.length} chunk(s) → amd/src/ + amd/build/`,
100
+ );
101
+ }
102
+
103
+ // Copy CSS to styles/ so Moodle auto-discovers it.
104
+ const assetsDir = resolve(outDir, "assets");
105
+ if (existsSync(assetsDir)) {
106
+ const stylesDir = resolve(pluginRoot, "styles");
107
+ if (!existsSync(stylesDir)) mkdirSync(stylesDir, {recursive: true});
108
+
109
+ const cssFiles = readdirSync(assetsDir).filter((f) =>
110
+ f.endsWith(".css"),
111
+ );
112
+ for (const file of cssFiles) {
113
+ copyFileSync(
114
+ resolve(assetsDir, file),
115
+ resolve(stylesDir, "middag-app.css"),
116
+ );
117
+ console.log(`[moodle-amd] ${file} → styles/middag-app.css`);
118
+ }
119
+ }
120
+ },
121
+ };
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-middag-ui",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "Bootstrap a MIDDAG React UI layer in your Moodle or WordPress plugin",
6
6
  "bin": {