create-middag-ui 0.16.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
@@ -2,7 +2,7 @@
2
2
  * scaffold.js — File creation for all scaffolded files.
3
3
  *
4
4
  * Creates directory structure, config files, page examples, app entry,
5
- * and Inertia mock adapters. Everything lives under src/.
5
+ * and Inertia mock adapters. Dev-only adapters live under mock/.
6
6
  *
7
7
  * Every I/O operation is wrapped with error handling.
8
8
  */
@@ -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
  /**
@@ -226,9 +244,10 @@ export default defineConfig({
226
244
  resolve: {
227
245
  alias: {
228
246
  "@/": resolve(__dirname, "src") + "/",
247
+ "@mock/": resolve(__dirname, "mock") + "/",
229
248
  ${adapterComment}
230
- "@inertiajs/react": resolve(__dirname, "src/adapters/inertia-react.ts"),
231
- "@inertiajs/core": resolve(__dirname, "src/adapters/inertia-core.ts"),
249
+ "@inertiajs/react": resolve(__dirname, "mock/adapters/inertia-react.ts"),
250
+ "@inertiajs/core": resolve(__dirname, "mock/adapters/inertia-core.ts"),
232
251
  },
233
252
  },
234
253
  });
@@ -240,10 +259,13 @@ export default defineConfig({
240
259
  /**
241
260
  * Scaffold eslint.config.js.
242
261
  */
243
- export function scaffoldEslintConfig(targetDir) {
262
+ export function scaffoldEslintConfig(targetDir, isPro = false) {
244
263
  const filePath = join(targetDir, "eslint.config.js");
245
264
  if (skipIfExists(filePath, "eslint.config.js")) return;
246
265
 
266
+ const ignores = ["dist/", "node_modules/", "scripts/", ...(isPro ? ["dist-licensed/"] : [])];
267
+ const ignoresStr = ignores.map((i) => `"${i}"`).join(", ");
268
+
247
269
  writeFile(
248
270
  filePath,
249
271
  `import js from "@eslint/js";
@@ -253,7 +275,7 @@ import reactRefresh from "eslint-plugin-react-refresh";
253
275
  import prettierConfig from "eslint-config-prettier";
254
276
 
255
277
  export default tseslint.config(
256
- { ignores: ["dist/", "node_modules/"] },
278
+ { ignores: [${ignoresStr}] },
257
279
  js.configs.recommended,
258
280
  ...tseslint.configs.recommended,
259
281
  {
@@ -263,7 +285,8 @@ export default tseslint.config(
263
285
  "react-refresh": reactRefresh,
264
286
  },
265
287
  rules: {
266
- ...reactHooks.configs.recommended.rules,
288
+ "react-hooks/rules-of-hooks": "error",
289
+ "react-hooks/exhaustive-deps": "warn",
267
290
  "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
268
291
  "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
269
292
  "@typescript-eslint/no-explicit-any": "warn",
@@ -293,6 +316,7 @@ export function scaffoldPrettierConfig(targetDir) {
293
316
  trailingComma: "all",
294
317
  printWidth: 100,
295
318
  plugins: ["prettier-plugin-tailwindcss"],
319
+ tailwindFunctions: ["cn", "cva"],
296
320
  },
297
321
  null,
298
322
  2,
@@ -301,6 +325,99 @@ export function scaffoldPrettierConfig(targetDir) {
301
325
  );
302
326
  }
303
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
+
304
421
  /**
305
422
  * Scaffold index.html at project root.
306
423
  */
@@ -934,12 +1051,12 @@ export const settingsContract: PageContract = {
934
1051
  */
935
1052
  export function scaffoldProApp(targetDir) {
936
1053
  ensureDir(join(targetDir, "src"));
937
- ensureDir(join(targetDir, "src", "adapters"));
1054
+ ensureDir(join(targetDir, "mock", "adapters"));
938
1055
 
939
1056
  // Inertia adapter shims — re-export from pre-built mock bundle
940
1057
  // so usePage() shares the same MockPageContext as MockPageProvider.
941
- const adapterReactPath = join(targetDir, "src", "adapters", "inertia-react.ts");
942
- if (!skipIfExists(adapterReactPath, "src/adapters/inertia-react.ts")) {
1058
+ const adapterReactPath = join(targetDir, "mock", "adapters", "inertia-react.ts");
1059
+ if (!skipIfExists(adapterReactPath, "mock/adapters/inertia-react.ts")) {
943
1060
  writeFile(adapterReactPath, `/**
944
1061
  * Mock @inertiajs/react — re-exports from pre-built @middag-io/react/mock.
945
1062
  *
@@ -947,16 +1064,16 @@ export function scaffoldProApp(targetDir) {
947
1064
  * MockPageProvider (both live in the pre-built ESM bundle).
948
1065
  */
949
1066
  export { usePage, Head, Link, router } from "@middag-io/react/mock";
950
- `, "src/adapters/inertia-react.ts");
1067
+ `, "mock/adapters/inertia-react.ts");
951
1068
  }
952
1069
 
953
- const adapterCorePath = join(targetDir, "src", "adapters", "inertia-core.ts");
954
- if (!skipIfExists(adapterCorePath, "src/adapters/inertia-core.ts")) {
1070
+ const adapterCorePath = join(targetDir, "mock", "adapters", "inertia-core.ts");
1071
+ if (!skipIfExists(adapterCorePath, "mock/adapters/inertia-core.ts")) {
955
1072
  writeFile(adapterCorePath, `/**
956
1073
  * Mock @inertiajs/core — re-exports from pre-built @middag-io/react/mock.
957
1074
  */
958
1075
  export { router, setMockNavigate } from "@middag-io/react/mock";
959
- `, "src/adapters/inertia-core.ts");
1076
+ `, "mock/adapters/inertia-core.ts");
960
1077
  }
961
1078
 
962
1079
  const mainPath = join(targetDir, "src", "main.tsx");
@@ -1053,13 +1170,13 @@ export function App() {
1053
1170
  }
1054
1171
 
1055
1172
  /**
1056
- * Scaffold FREE adapters: src/adapters/ with react-router.
1173
+ * Scaffold FREE adapters: mock/adapters/ with react-router.
1057
1174
  */
1058
1175
  export function scaffoldFreeAdapters(targetDir) {
1059
- ensureDir(join(targetDir, "src", "adapters"));
1176
+ ensureDir(join(targetDir, "mock", "adapters"));
1060
1177
 
1061
- const corePath = join(targetDir, "src", "adapters", "inertia-core.ts");
1062
- if (!skipIfExists(corePath, "src/adapters/inertia-core.ts")) {
1178
+ const corePath = join(targetDir, "mock", "adapters", "inertia-core.ts");
1179
+ if (!skipIfExists(corePath, "mock/adapters/inertia-core.ts")) {
1063
1180
  writeFile(corePath, `/**
1064
1181
  * Mock @inertiajs/core for standalone dev server.
1065
1182
  * Vite alias redirects @inertiajs/core here.
@@ -1078,11 +1195,11 @@ export const router = {
1078
1195
  visit: (url: string) => { _navigate ? _navigate(url) : console.log("[mock] VISIT", url); },
1079
1196
  on: () => () => {},
1080
1197
  };
1081
- `, "src/adapters/inertia-core.ts");
1198
+ `, "mock/adapters/inertia-core.ts");
1082
1199
  }
1083
1200
 
1084
- const reactPath = join(targetDir, "src", "adapters", "inertia-react.ts");
1085
- if (!skipIfExists(reactPath, "src/adapters/inertia-react.ts")) {
1201
+ const reactPath = join(targetDir, "mock", "adapters", "inertia-react.ts");
1202
+ if (!skipIfExists(reactPath, "mock/adapters/inertia-react.ts")) {
1086
1203
  writeFile(reactPath, `/**
1087
1204
  * Mock @inertiajs/react for standalone dev server (FREE).
1088
1205
  * Context-based usePage + react-router Link.
@@ -1127,7 +1244,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, MockLinkProps>(function
1127
1244
  });
1128
1245
 
1129
1246
  export { router };
1130
- `, "src/adapters/inertia-react.ts");
1247
+ `, "mock/adapters/inertia-react.ts");
1131
1248
  }
1132
1249
  }
1133
1250
 
@@ -1139,20 +1256,20 @@ export { router };
1139
1256
  * defining their own context (which would cause context mismatch).
1140
1257
  */
1141
1258
  export function scaffoldProAdapters(targetDir) {
1142
- ensureDir(join(targetDir, "src", "adapters"));
1259
+ ensureDir(join(targetDir, "mock", "adapters"));
1143
1260
 
1144
- const corePath = join(targetDir, "src", "adapters", "inertia-core.ts");
1145
- if (!skipIfExists(corePath, "src/adapters/inertia-core.ts")) {
1261
+ const corePath = join(targetDir, "mock", "adapters", "inertia-core.ts");
1262
+ if (!skipIfExists(corePath, "mock/adapters/inertia-core.ts")) {
1146
1263
  writeFile(corePath, `/**
1147
1264
  * Mock @inertiajs/core — PRO re-export from @middag-io/react/mock.
1148
1265
  * Shares the same navigate function as MockProductShell.
1149
1266
  */
1150
1267
  export { router, setMockNavigate } from "@middag-io/react/mock";
1151
- `, "src/adapters/inertia-core.ts (PRO)");
1268
+ `, "mock/adapters/inertia-core.ts (PRO)");
1152
1269
  }
1153
1270
 
1154
- const reactPath = join(targetDir, "src", "adapters", "inertia-react.ts");
1155
- if (!skipIfExists(reactPath, "src/adapters/inertia-react.ts")) {
1271
+ const reactPath = join(targetDir, "mock", "adapters", "inertia-react.ts");
1272
+ if (!skipIfExists(reactPath, "mock/adapters/inertia-react.ts")) {
1156
1273
  writeFile(reactPath, `/**
1157
1274
  * Mock @inertiajs/react — PRO re-export from @middag-io/react/mock.
1158
1275
  * Shares the same React context as MockPageProvider so usePage()
@@ -1160,7 +1277,7 @@ export { router, setMockNavigate } from "@middag-io/react/mock";
1160
1277
  */
1161
1278
  export { usePage, Head, Link } from "@middag-io/react/mock";
1162
1279
  export { router } from "./inertia-core";
1163
- `, "src/adapters/inertia-react.ts (PRO)");
1280
+ `, "mock/adapters/inertia-react.ts (PRO)");
1164
1281
  }
1165
1282
  }
1166
1283
 
@@ -1270,8 +1387,8 @@ createRoot(document.getElementById("root")!).render(
1270
1387
  import { BrowserRouter, Routes, Route, useNavigate } from "react-router";
1271
1388
  import { ContractPage, I18nProvider } from "@middag-io/react";
1272
1389
  import type { PageContract } from "@middag-io/react";
1273
- import { PageProvider } from "./adapters/inertia-react";
1274
- import { setMockNavigate } from "./adapters/inertia-core";
1390
+ import { PageProvider } from "@mock/adapters/inertia-react";
1391
+ import { setMockNavigate } from "@mock/adapters/inertia-core";
1275
1392
  import { dashboardContract } from "./pages/dashboard";
1276
1393
  import { connectorsContract } from "./pages/connectors";
1277
1394
  import { settingsContract } from "./pages/settings";
@@ -1374,26 +1491,91 @@ export function scaffoldHostEntry(targetDir, hostKey) {
1374
1491
  });
1375
1492
  observer.observe(document.body, { childList: true, subtree: true });`;
1376
1493
  } else if (hostKey === "moodle") {
1377
- extraImports = `import "./tailwind.css";
1378
- import "@fontsource-variable/figtree";`;
1379
- 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()));
1380
1540
 
1381
1541
  // Portal container for Radix UI (modals, popovers, toasts).
1382
1542
  // Radix portals default to document.body which is outside .middag-root,
1383
- // meaning scoped Tailwind styles won't apply. This creates a sibling
1384
- // container with .middag-root so portal content inherits design tokens.
1543
+ // so this sibling container ensures portal content inherits design tokens.
1385
1544
  if (!document.getElementById("middag-portals")) {
1386
- const portalContainer = document.createElement("div");
1387
- portalContainer.id = "middag-portals";
1388
- portalContainer.classList.add("middag-root");
1389
- document.body.appendChild(portalContainer);
1390
- }`;
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;
1391
1573
  }
1392
1574
 
1393
1575
  const content = `/**
1394
- * Production entry point for ${hostKey === "wordpress" ? "WordPress" : hostKey === "moodle" ? "Moodle" : "custom host"}.
1576
+ * Production entry point for ${hostKey === "wordpress" ? "WordPress" : "custom host"}.
1395
1577
  *
1396
- * 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"})
1397
1579
  * serves the HTML and Inertia page props. This file is the build target
1398
1580
  * for \`npm run build:${hostKey === "custom" ? "host" : hostKey}\`.
1399
1581
  *
@@ -1726,7 +1908,7 @@ body.middag-active [data-slot="sidebar-container"] {
1726
1908
  /** @deprecated Use scaffoldFreeApp + scaffoldFreeAdapters instead */
1727
1909
  export function scaffoldAppFiles(targetDir) {
1728
1910
  ensureDir(join(targetDir, "src"));
1729
- ensureDir(join(targetDir, "src", "adapters"));
1911
+ ensureDir(join(targetDir, "mock", "adapters"));
1730
1912
 
1731
1913
  // src/main.tsx — entry point
1732
1914
  const mainPath = join(targetDir, "src", "main.tsx");
@@ -1821,9 +2003,9 @@ export function App() {
1821
2003
  );
1822
2004
  }
1823
2005
 
1824
- // src/adapters/inertia-react.ts
1825
- const inertiaReactPath = join(targetDir, "src", "adapters", "inertia-react.ts");
1826
- if (!skipIfExists(inertiaReactPath, "src/adapters/inertia-react.ts")) {
2006
+ // mock/adapters/inertia-react.ts
2007
+ const inertiaReactPath = join(targetDir, "mock", "adapters", "inertia-react.ts");
2008
+ if (!skipIfExists(inertiaReactPath, "mock/adapters/inertia-react.ts")) {
1827
2009
  writeFile(
1828
2010
  inertiaReactPath,
1829
2011
  `/**
@@ -1934,13 +2116,13 @@ export const Link = React.forwardRef<HTMLAnchorElement, MockLinkProps>(function
1934
2116
 
1935
2117
  export { router };
1936
2118
  `,
1937
- "src/adapters/inertia-react.ts",
2119
+ "mock/adapters/inertia-react.ts",
1938
2120
  );
1939
2121
  }
1940
2122
 
1941
- // src/adapters/inertia-core.ts
1942
- const inertiaCorePath = join(targetDir, "src", "adapters", "inertia-core.ts");
1943
- if (!skipIfExists(inertiaCorePath, "src/adapters/inertia-core.ts")) {
2123
+ // mock/adapters/inertia-core.ts
2124
+ const inertiaCorePath = join(targetDir, "mock", "adapters", "inertia-core.ts");
2125
+ if (!skipIfExists(inertiaCorePath, "mock/adapters/inertia-core.ts")) {
1944
2126
  writeFile(
1945
2127
  inertiaCorePath,
1946
2128
  `/**
@@ -1960,7 +2142,7 @@ export const router = {
1960
2142
  on: () => () => {},
1961
2143
  };
1962
2144
  `,
1963
- "src/adapters/inertia-core.ts",
2145
+ "mock/adapters/inertia-core.ts",
1964
2146
  );
1965
2147
  }
1966
2148
  }
@@ -1995,6 +2177,37 @@ export function scaffoldPageResolver(targetDir) {
1995
2177
  writeFile(filePath, readTemplate("templates/shared/page-resolver.tsx"), "src/app/page-resolver.tsx");
1996
2178
  }
1997
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
+
1998
2211
  /**
1999
2212
  * Scaffold route helper: src/lib/routes.ts.
2000
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.16.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": {