create-middag-ui 0.17.0 → 0.18.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/cli.js CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  scaffoldDemoFiles,
30
30
  scaffoldDevShell,
31
31
  scaffoldEslintConfig,
32
+ scaffoldGitignore,
33
+ scaffoldInjectPlaceholders,
32
34
  scaffoldFreeAdapters,
33
35
  scaffoldFreeApp,
34
36
  scaffoldFreeRegister,
@@ -38,6 +40,8 @@ import {
38
40
  scaffoldHostViteConfig,
39
41
  scaffoldIndexHtml,
40
42
  scaffoldMoodleAdapters,
43
+ scaffoldMoodleAdminShell,
44
+ scaffoldMoodlePageResolver,
41
45
  scaffoldMoodlePlugin,
42
46
  scaffoldMoodleTailwind,
43
47
  scaffoldPackageJson,
@@ -166,11 +170,13 @@ if (!dirCreated) {
166
170
 
167
171
  heading(5, TOTAL_STEPS, "Scaffolding config files");
168
172
 
173
+ const isPro = registryPath === "github";
169
174
  scaffoldPackageJson(targetDir, host, cwd, registryPath, hostKey);
170
175
  scaffoldTsconfig(targetDir);
171
176
  scaffoldViteConfig(targetDir, host, registryPath);
172
- scaffoldEslintConfig(targetDir);
177
+ scaffoldEslintConfig(targetDir, isPro);
173
178
  scaffoldPrettierConfig(targetDir);
179
+ scaffoldGitignore(targetDir, isPro);
174
180
  scaffoldIndexHtml(targetDir);
175
181
 
176
182
  // ── Step 6: Scaffold ~/.npmrc (GitHub path only) ─────────────────────────
@@ -192,13 +198,16 @@ scaffoldDemoFiles(targetDir);
192
198
 
193
199
  // ── Step 8: Scaffold app + page examples (PRO vs FREE) ─────────────────
194
200
 
195
- const isPro = registryPath === "github";
196
201
  heading(8, TOTAL_STEPS, `Creating ${isPro ? "PRO" : "FREE"} UI module`);
197
202
 
198
203
  scaffoldPageExamples(targetDir);
199
204
 
200
- // Shared files (both PRO and FREE)
201
- scaffoldPageResolver(targetDir);
205
+ // Page resolver — Moodle gets AMD version (RequireJS + extension globs)
206
+ if (hostKey === "moodle") {
207
+ scaffoldMoodlePageResolver(targetDir);
208
+ } else {
209
+ scaffoldPageResolver(targetDir);
210
+ }
202
211
  scaffoldDemoDirectPage(targetDir);
203
212
  // Convert frankenstyle "local_middag" → Moodle path "local/middag"
204
213
  const moodlePath = moodleComponent ? moodleComponent.replace("_", "/") : null;
@@ -217,6 +226,7 @@ if (isPro) {
217
226
  // PRO Inertia adapters — re-export from @middag-io/react/mock so usePage()
218
227
  // shares the same React context as MockPageProvider (no context mismatch).
219
228
  scaffoldProAdapters(targetDir);
229
+ scaffoldInjectPlaceholders(targetDir);
220
230
  success("PRO: using MockProductShell from @middag-io/react/mock");
221
231
  } catch {
222
232
  // npm version — PRO file excluded, fall back to FREE
@@ -245,7 +255,8 @@ if (hostKey === "moodle" && moodleComponent) {
245
255
  scaffoldMoodlePlugin(targetDir, moodleComponent);
246
256
  scaffoldMoodleTailwind(targetDir);
247
257
  scaffoldMoodleAdapters(targetDir, moodleComponent);
248
- success(`Moodle: AMD plugin + Tailwind + adapters for ${ moodleComponent }`);
258
+ scaffoldMoodleAdminShell(targetDir);
259
+ success(`Moodle: AMD plugin + Tailwind + adapters + AdminShell for ${ moodleComponent }`);
249
260
  }
250
261
 
251
262
  // ── Step 9: npm install ──────────────────────────────────────────────────
package/lib/scaffold.js CHANGED
@@ -127,17 +127,30 @@ export function scaffoldPackageJson(targetDir, host, cwd, registryPath, hostKey)
127
127
  if (hostKey === "wordpress") {
128
128
  scripts["build:wp"] = "vite build --config vite.config.wordpress.ts";
129
129
  scripts["watch:wp"] = "vite build --config vite.config.wordpress.ts --watch";
130
+ if (isPro) {
131
+ scripts["build:licensed"] =
132
+ "vite build --config vite.config.wordpress.ts && node scripts/inject-placeholders.mjs";
133
+ }
130
134
  } else if (hostKey === "moodle") {
131
135
  scripts["build:moodle"] = "vite build --config vite.config.moodle.ts";
132
136
  scripts["watch:moodle"] = "vite build --config vite.config.moodle.ts --watch";
137
+ if (isPro) {
138
+ scripts["build:licensed"] =
139
+ "vite build --config vite.config.moodle.ts && node scripts/inject-placeholders.mjs";
140
+ }
133
141
  } else if (hostKey === "custom") {
134
142
  scripts["build:host"] = "vite build --config vite.config.custom.ts";
135
143
  scripts["watch:host"] = "vite build --config vite.config.custom.ts --watch";
144
+ if (isPro) {
145
+ scripts["build:licensed"] =
146
+ "vite build --config vite.config.custom.ts && node scripts/inject-placeholders.mjs";
147
+ }
136
148
  }
137
149
 
138
150
  const pkg = {
139
151
  name: `${projectName}-ui`,
140
152
  private: true,
153
+ version: "0.1.0",
141
154
  type: "module",
142
155
  scripts,
143
156
  dependencies: deps,
@@ -190,6 +203,11 @@ export function scaffoldTsconfig(targetDir) {
190
203
  };
191
204
 
192
205
  writeFile(filePath, JSON.stringify(tsconfig, null, 2) + "\n", "tsconfig.json");
206
+
207
+ const envFilePath = join(targetDir, "src", "vite-env.d.ts");
208
+ if (!skipIfExists(envFilePath, "src/vite-env.d.ts")) {
209
+ writeFile(envFilePath, `/// <reference types="vite/client" />\n`, "src/vite-env.d.ts");
210
+ }
193
211
  }
194
212
 
195
213
  /**
@@ -241,10 +259,13 @@ export default defineConfig({
241
259
  /**
242
260
  * Scaffold eslint.config.js.
243
261
  */
244
- export function scaffoldEslintConfig(targetDir) {
262
+ export function scaffoldEslintConfig(targetDir, isPro = false) {
245
263
  const filePath = join(targetDir, "eslint.config.js");
246
264
  if (skipIfExists(filePath, "eslint.config.js")) return;
247
265
 
266
+ const ignores = ["dist/", "node_modules/", "scripts/", ...(isPro ? ["dist-licensed/"] : [])];
267
+ const ignoresStr = ignores.map((i) => `"${i}"`).join(", ");
268
+
248
269
  writeFile(
249
270
  filePath,
250
271
  `import js from "@eslint/js";
@@ -254,7 +275,7 @@ import reactRefresh from "eslint-plugin-react-refresh";
254
275
  import prettierConfig from "eslint-config-prettier";
255
276
 
256
277
  export default tseslint.config(
257
- { ignores: ["dist/", "node_modules/"] },
278
+ { ignores: [${ignoresStr}] },
258
279
  js.configs.recommended,
259
280
  ...tseslint.configs.recommended,
260
281
  {
@@ -264,7 +285,8 @@ export default tseslint.config(
264
285
  "react-refresh": reactRefresh,
265
286
  },
266
287
  rules: {
267
- ...reactHooks.configs.recommended.rules,
288
+ "react-hooks/rules-of-hooks": "error",
289
+ "react-hooks/exhaustive-deps": "warn",
268
290
  "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
269
291
  "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
270
292
  "@typescript-eslint/no-explicit-any": "warn",
@@ -294,6 +316,7 @@ export function scaffoldPrettierConfig(targetDir) {
294
316
  trailingComma: "all",
295
317
  printWidth: 100,
296
318
  plugins: ["prettier-plugin-tailwindcss"],
319
+ tailwindFunctions: ["cn", "cva"],
297
320
  },
298
321
  null,
299
322
  2,
@@ -302,6 +325,99 @@ export function scaffoldPrettierConfig(targetDir) {
302
325
  );
303
326
  }
304
327
 
328
+ /**
329
+ * Scaffold .gitignore with UI-specific dist patterns.
330
+ */
331
+ export function scaffoldGitignore(targetDir, isPro = false) {
332
+ const filePath = join(targetDir, ".gitignore");
333
+ if (skipIfExists(filePath, ".gitignore")) return;
334
+
335
+ const lines = [
336
+ "node_modules/",
337
+ "dist/",
338
+ ...(isPro ? ["dist-licensed/"] : []),
339
+ "dist-master/",
340
+ "dist-mock/",
341
+ "dist-lib/",
342
+ "*.log",
343
+ ".vite/",
344
+ ];
345
+ writeFile(filePath, lines.join("\n") + "\n", ".gitignore");
346
+ }
347
+
348
+ /**
349
+ * Scaffold scripts/inject-placeholders.mjs (PRO only).
350
+ * Injects opaque placeholder variables into middag-* bundles for CDN delivery.
351
+ * The Cloudflare Worker replaces placeholders with per-installation values.
352
+ */
353
+ export function scaffoldInjectPlaceholders(targetDir) {
354
+ const scriptsDir = join(targetDir, "scripts");
355
+ mkdirSync(scriptsDir, { recursive: true });
356
+ const filePath = join(scriptsDir, "inject-placeholders.mjs");
357
+ if (skipIfExists(filePath, "scripts/inject-placeholders.mjs")) return;
358
+
359
+ writeFile(
360
+ filePath,
361
+ `/**
362
+ * inject-placeholders — generate licensed bundles for CDN delivery.
363
+ *
364
+ * Takes compiled Vite output from dist/ and produces dist-licensed/ with
365
+ * placeholder variables injected into middag-* bundles. The Cloudflare
366
+ * Worker replaces these placeholders with real values per installation.
367
+ *
368
+ * Usage: node scripts/inject-placeholders.mjs
369
+ * Called by: npm run build:licensed
370
+ */
371
+ import fs from "node:fs";
372
+ import path from "node:path";
373
+
374
+ const DIST_DIR = path.resolve(import.meta.dirname, "../dist");
375
+ const LICENSED_DIR = path.resolve(import.meta.dirname, "../dist-licensed");
376
+
377
+ // Variable names are intentionally opaque to discourage reverse engineering.
378
+ // The mapping is documented only here and in the Worker source:
379
+ // _0x7a3f → wwwroot (replaced by Worker with installation URL)
380
+ // _0x9b1e → expiry (replaced by Worker with unix timestamp)
381
+ // _0x4d2c → tier (replaced by Worker with license tier)
382
+ // _0x6e8a → domain hash (replaced by Worker with HMAC of wwwroot)
383
+ const PLACEHOLDER_HEADER = [
384
+ 'var _0x7a3f="__PH_0x7a3f__";',
385
+ "var _0x9b1e=0;",
386
+ 'var _0x4d2c="__PH_0x4d2c__";',
387
+ 'var _0x6e8a="__PH_0x6e8a__";',
388
+ ].join("") + "\\n";
389
+
390
+ if (fs.existsSync(LICENSED_DIR)) fs.rmSync(LICENSED_DIR, { recursive: true });
391
+ fs.mkdirSync(LICENSED_DIR, { recursive: true });
392
+
393
+ const files = fs.readdirSync(DIST_DIR).filter((f) => f.endsWith(".js"));
394
+ let injected = 0;
395
+ let copied = 0;
396
+
397
+ for (const file of files) {
398
+ const srcPath = path.join(DIST_DIR, file);
399
+ const destPath = path.join(LICENSED_DIR, file);
400
+ const content = fs.readFileSync(srcPath, "utf-8");
401
+
402
+ if (file.startsWith("middag-")) {
403
+ fs.writeFileSync(destPath, PLACEHOLDER_HEADER + content);
404
+ injected++;
405
+ console.log(\`[licensed] \${file} — placeholders injected\`);
406
+ } else {
407
+ fs.copyFileSync(srcPath, destPath);
408
+ copied++;
409
+ console.log(\`[licensed] \${file} — copied (vendor/shared)\`);
410
+ }
411
+ }
412
+
413
+ console.log(\`\\nLicensed bundles ready in \${LICENSED_DIR}/\`);
414
+ console.log(\` \${injected} bundle(s) with placeholders\`);
415
+ console.log(\` \${copied} bundle(s) copied as-is\`);
416
+ `,
417
+ "scripts/inject-placeholders.mjs",
418
+ );
419
+ }
420
+
305
421
  /**
306
422
  * Scaffold index.html at project root.
307
423
  */
@@ -1375,26 +1491,91 @@ export function scaffoldHostEntry(targetDir, hostKey) {
1375
1491
  });
1376
1492
  observer.observe(document.body, { childList: true, subtree: true });`;
1377
1493
  } else if (hostKey === "moodle") {
1378
- extraImports = `import "./tailwind.css";
1379
- import "@fontsource-variable/figtree";`;
1380
- setupCode = ` document.body.classList.add("middag-active");
1494
+ // Moodle uses a dedicated content block — see below.
1495
+ }
1496
+
1497
+ // Moodle: fully separate entry (asyncResolver, full provider stack, theme init, AdminShell)
1498
+ if (hostKey === "moodle") {
1499
+ const moodleContent = `/**
1500
+ * Production entry point for Moodle.
1501
+ *
1502
+ * AMD module loaded by Moodle's RequireJS via js_call_amd().
1503
+ * Uses real createInertiaApp — Moodle serves the HTML and page props.
1504
+ * This is the build target for \`npm run build\`.
1505
+ *
1506
+ * NOT used by \`npm run dev\` — that uses src/main.tsx with mock adapters.
1507
+ */
1508
+ import "./tailwind.css";
1509
+ import "@fontsource-variable/figtree";
1510
+
1511
+ import { createRoot } from "react-dom/client";
1512
+ import { createInertiaApp } from "@inertiajs/react";
1513
+ import {
1514
+ I18nProvider,
1515
+ ProgressProvider,
1516
+ AuthProvider,
1517
+ FlashProvider,
1518
+ ScopeProvider,
1519
+ setAppearance,
1520
+ getStoredAppearance,
1521
+ getEffectiveTheme,
1522
+ onSystemThemeChange,
1523
+ } from "@middag-io/react";
1524
+ import "@middag-io/react/style.css";
1525
+ import { getString } from "./lib/moodle/strings";
1526
+ import { registerDefaults } from "./app/register";
1527
+ import { resolvePageComponent } from "./app/page-resolver";
1528
+
1529
+ registerDefaults();
1530
+ setAppearance(getStoredAppearance());
1531
+ onSystemThemeChange();
1532
+
1533
+ createInertiaApp({
1534
+ id: "middag-app",
1535
+ resolve: (name) => resolvePageComponent(name),
1536
+ setup({ el, App, props }) {
1537
+ el.classList.add("middag-root");
1538
+ el.classList.add("middag-active");
1539
+ el.setAttribute("data-theme", getEffectiveTheme(getStoredAppearance()));
1381
1540
 
1382
1541
  // Portal container for Radix UI (modals, popovers, toasts).
1383
1542
  // Radix portals default to document.body which is outside .middag-root,
1384
- // meaning scoped Tailwind styles won't apply. This creates a sibling
1385
- // container with .middag-root so portal content inherits design tokens.
1543
+ // so this sibling container ensures portal content inherits design tokens.
1386
1544
  if (!document.getElementById("middag-portals")) {
1387
- const portalContainer = document.createElement("div");
1388
- portalContainer.id = "middag-portals";
1389
- portalContainer.classList.add("middag-root");
1390
- document.body.appendChild(portalContainer);
1391
- }`;
1545
+ const portals = document.createElement("div");
1546
+ portals.id = "middag-portals";
1547
+ portals.classList.add("middag-root");
1548
+ document.body.appendChild(portals);
1549
+ }
1550
+
1551
+ createRoot(el).render(
1552
+ <ProgressProvider>
1553
+ <App {...props}>
1554
+ {({ Component, props: pageProps, key }) => (
1555
+ <AuthProvider>
1556
+ <ScopeProvider>
1557
+ <FlashProvider>
1558
+ <I18nProvider asyncResolver={getString}>
1559
+ <Component key={key} {...pageProps} />
1560
+ </I18nProvider>
1561
+ </FlashProvider>
1562
+ </ScopeProvider>
1563
+ </AuthProvider>
1564
+ )}
1565
+ </App>
1566
+ </ProgressProvider>,
1567
+ );
1568
+ },
1569
+ });
1570
+ `;
1571
+ writeFile(filePath, moodleContent, label);
1572
+ return;
1392
1573
  }
1393
1574
 
1394
1575
  const content = `/**
1395
- * Production entry point for ${hostKey === "wordpress" ? "WordPress" : hostKey === "moodle" ? "Moodle" : "custom host"}.
1576
+ * Production entry point for ${hostKey === "wordpress" ? "WordPress" : "custom host"}.
1396
1577
  *
1397
- * Uses real createInertiaApp — the host platform (${hostKey === "wordpress" ? "WP" : hostKey === "moodle" ? "Moodle" : "your backend"})
1578
+ * Uses real createInertiaApp — the host platform (${hostKey === "wordpress" ? "WP" : "your backend"})
1398
1579
  * serves the HTML and Inertia page props. This file is the build target
1399
1580
  * for \`npm run build:${hostKey === "custom" ? "host" : hostKey}\`.
1400
1581
  *
@@ -1996,6 +2177,37 @@ export function scaffoldPageResolver(targetDir) {
1996
2177
  writeFile(filePath, readTemplate("templates/shared/page-resolver.tsx"), "src/app/page-resolver.tsx");
1997
2178
  }
1998
2179
 
2180
+ /**
2181
+ * Scaffold Moodle AMD page resolver: src/app/page-resolver.tsx.
2182
+ *
2183
+ * Overwrites the generic resolver with a Moodle-specific version that supports:
2184
+ * - Eager core pages (../extensions/core/pages/**)
2185
+ * - Lazy non-core extension pages (separate AMD chunks)
2186
+ * - External plugin pages via RequireJS (frankenstyle prefix)
2187
+ *
2188
+ * @param {string} targetDir - Absolute path to UI project root
2189
+ */
2190
+ export function scaffoldMoodlePageResolver(targetDir) {
2191
+ ensureDir(join(targetDir, "src", "app"));
2192
+ const filePath = join(targetDir, "src", "app", "page-resolver.tsx");
2193
+ writeFile(filePath, readTemplate("templates/shared/page-resolver-moodle.tsx"), "src/app/page-resolver.tsx (Moodle AMD)");
2194
+ }
2195
+
2196
+ /**
2197
+ * Scaffold Moodle admin shell: src/shells/AdminShell.tsx.
2198
+ *
2199
+ * Moodle-specific shell that supports the admin_tabs shared prop.
2200
+ * Registered as 'admin' shell in src/entry-moodle.tsx.
2201
+ *
2202
+ * @param {string} targetDir - Absolute path to UI project root
2203
+ */
2204
+ export function scaffoldMoodleAdminShell(targetDir) {
2205
+ ensureDir(join(targetDir, "src", "shells"));
2206
+ const filePath = join(targetDir, "src", "shells", "AdminShell.tsx");
2207
+ if (skipIfExists(filePath, "src/shells/AdminShell.tsx")) return;
2208
+ writeFile(filePath, readTemplate("templates/shared/moodle-admin-shell.tsx"), "src/shells/AdminShell.tsx");
2209
+ }
2210
+
1999
2211
  /**
2000
2212
  * Scaffold route helper: src/lib/routes.ts.
2001
2213
  * Abstracts host admin URL vs dev mock path.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * AdminShell — Moodle-specific admin shell.
3
+ *
4
+ * Extends ProductShell architecture with an optional admin tab bar
5
+ * rendered when the `admin_tabs` Inertia shared prop is present.
6
+ *
7
+ * Registered as 'admin' shell in src/entry-moodle.tsx.
8
+ * Edit this file to customise the admin sidebar header, tab bar, or layout.
9
+ */
10
+ import { useCallback, type ReactElement } from "react";
11
+ import { Link, usePage } from "@inertiajs/react";
12
+ import {
13
+ Toaster,
14
+ Sidebar,
15
+ SidebarHeader,
16
+ SidebarProvider,
17
+ useSidebar,
18
+ SidebarNav,
19
+ PageHeader,
20
+ NavErrorBoundary,
21
+ type ShellProps,
22
+ type SharedProps,
23
+ type PageMeta,
24
+ type AdminTabsProps,
25
+ } from "@middag-io/react";
26
+ import { Tabs, TabsList, TabsTrigger } from "@middag-io/react/reui/tabs";
27
+
28
+ // ── Inner shell (must be inside SidebarProvider) ──────────────────────────────
29
+
30
+ function AdminShellInner({ children }: ShellProps): ReactElement {
31
+ const { setOpenMobile } = useSidebar();
32
+ const { props } = usePage<SharedProps>();
33
+
34
+ const page: PageMeta = (props as SharedProps & { contract?: { page?: PageMeta } }).contract
35
+ ?.page ?? { key: "unknown", title: "", breadcrumbs: [], actions: [] };
36
+
37
+ const adminTabs = (props as SharedProps & { admin_tabs?: AdminTabsProps }).admin_tabs;
38
+
39
+ const handleMobileMenuClick = useCallback(() => {
40
+ setOpenMobile(true);
41
+ }, [setOpenMobile]);
42
+
43
+ return (
44
+ <>
45
+ <Sidebar
46
+ aria-label="Navegação"
47
+ collapsible="icon"
48
+ className="border-sidebar-border bg-sidebar border-r"
49
+ >
50
+ <SidebarHeader className="border-sidebar-border border-b px-4 py-3">
51
+ {/* Replace with your plugin name or logo */}
52
+ <p className="text-sidebar-foreground text-sm font-semibold">Admin</p>
53
+ </SidebarHeader>
54
+
55
+ <NavErrorBoundary>
56
+ <SidebarNav />
57
+ </NavErrorBoundary>
58
+ </Sidebar>
59
+
60
+ <div className="admin-shell__content flex min-h-screen flex-1 flex-col overflow-hidden">
61
+ <NavErrorBoundary>
62
+ <PageHeader page={page} onMobileMenuClick={handleMobileMenuClick} />
63
+ </NavErrorBoundary>
64
+
65
+ {adminTabs && (
66
+ <div className="border-b px-6">
67
+ <Tabs value={adminTabs.active}>
68
+ <TabsList variant="line" className="w-full justify-start">
69
+ {adminTabs.items.map((tab) => (
70
+ <TabsTrigger key={tab.key} value={tab.key} asChild>
71
+ <Link href={tab.href} preserveState>
72
+ {tab.label}
73
+ </Link>
74
+ </TabsTrigger>
75
+ ))}
76
+ </TabsList>
77
+ </Tabs>
78
+ </div>
79
+ )}
80
+
81
+ <main className="flex-1 overflow-auto p-6" aria-live="polite" aria-busy="false">
82
+ {children}
83
+ </main>
84
+ </div>
85
+
86
+ <Toaster position="bottom-right" richColors />
87
+ </>
88
+ );
89
+ }
90
+
91
+ // ── AdminShell — public export ──────────────────────────────────────────────
92
+
93
+ export function AdminShell({ children }: ShellProps): ReactElement {
94
+ return (
95
+ <SidebarProvider
96
+ defaultOpen={true}
97
+ style={
98
+ {
99
+ "--sidebar-width": "var(--sidebar-width)",
100
+ "--sidebar-width-icon": "var(--sidebar-width-collapsed)",
101
+ } as React.CSSProperties
102
+ }
103
+ className="bg-background text-foreground flex min-h-screen"
104
+ >
105
+ <AdminShellInner>{children}</AdminShellInner>
106
+ </SidebarProvider>
107
+ );
108
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * page-resolver — Moodle AMD page resolver.
3
+ *
4
+ * Three resolution modes:
5
+ *
6
+ * 1. Core pages (no prefix): "admin/Dashboard" resolves to
7
+ * "../extensions/core/pages/admin/Dashboard.tsx" via eager glob.
8
+ * Bundled into middag-app.js.
9
+ *
10
+ * 2. Extension pages (extension prefix): "ecommerce/pages/Index" resolves
11
+ * to "../extensions/ecommerce/pages/Index.tsx" via lazy glob.
12
+ * Each non-core extension gets its own AMD chunk.
13
+ *
14
+ * 3. External plugin pages (frankenstyle prefix): "local_yourplugin/admin/Index"
15
+ * loads the AMD module via RequireJS. Any Moodle plugin can register
16
+ * React pages in the MIDDAG shell using this mechanism.
17
+ */
18
+
19
+ // Core pages — eager (bundled into middag-app.js)
20
+ const corePages = import.meta.glob("../extensions/core/pages/**/*.tsx", { eager: true }) as Record<
21
+ string,
22
+ Record<string, unknown>
23
+ >;
24
+
25
+ // Non-core extension pages — lazy (separate AMD chunks)
26
+ const extensionPages = import.meta.glob(
27
+ ["../extensions/*/pages/**/*.tsx", "!../extensions/core/**"],
28
+ { eager: false },
29
+ ) as Record<string, () => Promise<Record<string, unknown>>>;
30
+
31
+ /**
32
+ * Frankenstyle pattern: type_name (e.g. local_yourplugin, mod_assign).
33
+ * A name like "local_yourplugin/admin/Index" is external.
34
+ * A name like "admin/Dashboard" is local (no underscore in first segment).
35
+ */
36
+ function isExternalPage(name: string): boolean {
37
+ const firstSegment = name.split("/")[0];
38
+ return /^[a-z]+_[a-z]/.test(firstSegment);
39
+ }
40
+
41
+ /**
42
+ * Load an external plugin page via RequireJS (Moodle AMD).
43
+ *
44
+ * The plugin must build its page as an AMD module that exports a
45
+ * default React component. The module ID matches the page name directly
46
+ * (e.g. "local_yourplugin/admin/Index").
47
+ */
48
+ function loadExternalPage(name: string): Promise<Record<string, unknown>> {
49
+ return new Promise((resolve, reject) => {
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RequireJS global
51
+ const req = (window as any).require;
52
+ if (!req) {
53
+ reject(new Error(`RequireJS not available. Cannot load external page: ${name}`));
54
+ return;
55
+ }
56
+ req(
57
+ [name],
58
+ (mod: Record<string, unknown>) => resolve(mod),
59
+ (err: Error) => reject(new Error(`Failed to load external page "${name}": ${err.message}`)),
60
+ );
61
+ });
62
+ }
63
+
64
+ export function resolvePageComponent(name: string) {
65
+ if (isExternalPage(name)) {
66
+ return loadExternalPage(name).then((mod) => mod.default ?? mod);
67
+ }
68
+
69
+ // Try core pages first (eager/sync)
70
+ const corePath = `../extensions/core/pages/${name}.tsx`;
71
+ const coreMod = corePages[corePath];
72
+ if (coreMod) {
73
+ return coreMod.default ?? coreMod;
74
+ }
75
+
76
+ // Try non-core extension pages (lazy/async)
77
+ const extPath = `../extensions/${name}.tsx`;
78
+ const extLoader = extensionPages[extPath];
79
+ if (extLoader) {
80
+ return extLoader().then((mod) => mod.default ?? mod);
81
+ }
82
+
83
+ throw new Error(
84
+ `Page not found: ${name}. Available: ${[...Object.keys(corePages), ...Object.keys(extensionPages)].join(", ")}`,
85
+ );
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-middag-ui",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "description": "Bootstrap a MIDDAG React UI layer in your Moodle or WordPress plugin",
6
6
  "bin": {