vite-plugin-milpa 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 @Calcifux (Carlos Guillermo Reyes Ramiro)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # vite-plugin-milpa
2
+
3
+ > Vite integration for the [milpa](https://pypi.org/project/milpa-core/) framework (FastAPI + Jinja),
4
+ > in the spirit of `laravel-vite-plugin`. Docs below in Spanish — milpa's home language.
5
+
6
+ El `laravel-vite-plugin` de **milpa**: conecta tu app Vite (React, Vue, Svelte, vanilla…)
7
+ con el backend milpa, que sirve el shell Jinja e inyecta tus assets con su helper `vite()`.
8
+
9
+ ```js
10
+ // vite.config.js — todo el pegamento en una llamada
11
+ import {defineConfig} from 'vite';
12
+ import react from '@vitejs/plugin-react';
13
+ import {milpa} from 'vite-plugin-milpa';
14
+
15
+ export default defineConfig({
16
+ plugins: [
17
+ react(),
18
+ milpa({
19
+ entry: 'src/main.jsx',
20
+ pwa: {shell: '/spa'}, // opcional — omite para un SPA sin PWA
21
+ }),
22
+ ],
23
+ });
24
+ ```
25
+
26
+ ```jinja
27
+ {# El template Jinja de TU app (milpa lo sirve) #}
28
+ {{ vite_react_refresh(app='demo-spa') }}
29
+ {{ vite('src/main.jsx', app='demo-spa') }}
30
+ ```
31
+
32
+ ## Qué hace
33
+
34
+ 1. **`base` + `build.manifest` + entry** — lo que el helper `vite()` de milpa necesita para
35
+ emitir `<link>`/`<script>` hasheados en prod. La base se deriva de la carpeta:
36
+ `surcos/<app>` → `/vite/<app>/`.
37
+ 2. **Build a `public/<app>` del proyecto** (estilo `mix.js` → `public/` de Laravel): milpa
38
+ monta `public/` completo y un solo mount sirve a todas las apps.
39
+ 3. **Hot-file (modelo Laravel)** — `npm run dev` escribe `./hot` con la URL real del dev
40
+ server y lo borra al apagarse; milpa decide dev/prod por su existencia. Un hot-file POR
41
+ app: tu equipo en dev con HMR mientras los demás corren su build.
42
+ 4. **PWA opcional** ([Serwist](https://serwist.pages.dev) `injectManifest`) — compila
43
+ `src/sw.js` a un `sw.js` ESTÁTICO junto al build, precachea el shell del backend y
44
+ prefija los precache entries con la base (los relativos 404ean bajo subpath). Solo en
45
+ build: en dev el SW pelearía con HMR.
46
+ 5. **`ASSET_URL`** (env, solo build) — se antepone a la base derivada, igual que en
47
+ laravel-vite-plugin: deploy bajo sub-ruta de reverse proxy (`ASSET_URL=/nombre-reverse`)
48
+ o CDN, sin tocar el vite.config. Es la MISMA env var que lee el backend milpa en runtime.
49
+
50
+ ## Opciones
51
+
52
+ | Opción | Default | Qué controla |
53
+ |---|---|---|
54
+ | `entry` | `'src/main.jsx'` | Entry point (va a `rollupOptions.input`) |
55
+ | `assetsUrl` | `${ASSET_URL}/vite/<carpeta>` | URL pública donde milpa sirve esta app |
56
+ | `publicDir` | `'../../public'` | `public/` del proyecto, relativo a la app |
57
+ | `hotFile` | `'hot'` | Ruta del hot-file, relativa a la app |
58
+ | `pwa` | `false` | `false` \| `{swSrc, swDest, shell, globDirectory}` |
59
+
60
+ Nada es mandatorio: cualquier opción se overridea, o no uses el plugin y configura a mano —
61
+ milpa solo necesita el manifest, la base y el hot-file.
62
+
63
+ ## `vite-plugin-milpa/router` — file-based routing (react-router 7)
64
+
65
+ El subpath **runtime** del paquete (mismo patrón que `@serwist/vite/worker`): rutas por
66
+ convención de archivos para `createBrowserRouter` (modo library — el que usa un SPA servido
67
+ por un backend), sin plugin extra, sin codegen, sin virtual modules. ~70 líneas auditables.
68
+
69
+ ```js
70
+ // src/router.jsx de TU app — los globs van en TU código (import.meta.glob es
71
+ // compile-time y resuelve relativo al archivo que lo contiene):
72
+ import {buildRoutes} from 'vite-plugin-milpa/router';
73
+
74
+ const routes = buildRoutes({
75
+ pages: import.meta.glob('./pages/**/*.jsx'), // rutas core del shell
76
+ modules: import.meta.glob('./modules/*/pages/**/*.jsx'), // rutas por módulo /<m>/...
77
+ });
78
+ ```
79
+
80
+ | Convención | Resultado |
81
+ |---|---|
82
+ | `pages/acerca.jsx` | `/acerca` |
83
+ | `pages/index.jsx` | `/` (índice de su carpeta) |
84
+ | `pages/productos/[id].jsx` | `/productos/:id` (`useParams()`) |
85
+ | `modules/tienda/pages/index.jsx` | `/tienda` (espejo de `Modules/<X>/Http` del backend) |
86
+ | `modules/tienda/pages/_layout.jsx` | layout del módulo (envuelve sus rutas; opcional) |
87
+ | `_loquesea.jsx` | ignorado (prefijo `_` = no-ruta) |
88
+
89
+ Globs SIN eager ⇒ cada página es un chunk propio (code-splitting por ruta gratis). Acepta
90
+ `.jsx/.tsx/.js/.ts`. Peers: `react >=18` y `react-router >=7` — **opcionales**: un surco
91
+ vanilla usa el plugin sin React y sin warnings (solo los necesita quien importa `/router`).
92
+
93
+ ## El lado milpa
94
+
95
+ Requiere milpa-core con el helper Vite (`Core/View/Vite.py`): settings `VITE_APPS_DIR`
96
+ (default `surcos`), `VITE_PUBLIC_DIR` (default `public`), `VITE_ASSETS_URL` (default `/vite`).
97
+ Sin apps detectadas, la integración simplemente no se activa.
98
+
99
+ ## Licencia
100
+
101
+ [MIT](./LICENSE)
package/index.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ // Tipos de vite-plugin-milpa (el plugin build-time). El fuente es JS plano
2
+ // auditable a propósito (lo que está en npm ES lo que corre); este .d.ts da el
3
+ // soporte TypeScript de primera. Subpath runtime: ver router.d.ts.
4
+ import type {Plugin} from 'vite';
5
+
6
+ export interface MilpaPwaOptions {
7
+ /** Fuente del Service Worker, relativo a la app. Default: 'src/sw.js'. */
8
+ swSrc?: string;
9
+ /** Nombre del SW dentro del build. Default: 'sw.js'. */
10
+ swDest?: string;
11
+ /**
12
+ * Ruta del shell del backend a precachear (cold-start offline + fallback),
13
+ * p. ej. '/spa'. Bajo reverse proxy con sub-ruta, antepón el prefijo del
14
+ * deploy (es build-time, igual que la base de los chunks).
15
+ */
16
+ shell?: string;
17
+ /** Carpeta a globear para el precache. Default: el outDir del build. */
18
+ globDirectory?: string;
19
+ }
20
+
21
+ export interface MilpaOptions {
22
+ /** Entry de la app. Default: 'src/main.jsx'. */
23
+ entry?: string;
24
+ /**
25
+ * Base pública EXPLÍCITA de los assets (toma control total). Default
26
+ * derivada: `${ASSET_URL}/vite/<nombre-de-la-carpeta>/` — ASSET_URL es la
27
+ * misma env var que lee el backend milpa en runtime (deploy bajo sub-ruta
28
+ * de reverse proxy o CDN).
29
+ */
30
+ assetsUrl?: string | null;
31
+ /**
32
+ * Raíz public/ del PROYECTO backend (estilo Laravel), relativa a la app:
33
+ * el build cae en `<publicDir>/<app>/`. Default: '../../public'.
34
+ */
35
+ publicDir?: string;
36
+ /** Ruta del hot-file que decide dev/prod (modelo Laravel). Default: 'hot'. */
37
+ hotFile?: string;
38
+ /** PWA opcional con Serwist (injectManifest). Default: false. */
39
+ pwa?: boolean | MilpaPwaOptions;
40
+ }
41
+
42
+ /**
43
+ * TODO el pegamento backend-integration de milpa en una llamada (= el
44
+ * laravel-vite-plugin de milpa): base + manifest + outDir, hot-file para HMR,
45
+ * y PWA opcional. Cada opción se puede overridear; nada es mandatorio.
46
+ */
47
+ export declare function milpa(options?: MilpaOptions): Plugin[];
package/index.js ADDED
@@ -0,0 +1,145 @@
1
+ // vite-plugin-milpa — el "laravel-vite-plugin" de milpa (tu mix.js, pero mínimo).
2
+ //
3
+ // TODO el pegamento backend-integration en UNA llamada en vite.config.js:
4
+ //
5
+ // import {milpa} from 'vite-plugin-milpa';
6
+ // export default defineConfig({
7
+ // plugins: [react(), milpa({pwa: {shell: '/spa'}})],
8
+ // });
9
+ //
10
+ // Qué hace por ti (cada pieza es lo que antes se configuraba a mano):
11
+ // 1. base + build.manifest + rollupOptions.input → lo que el helper Jinja
12
+ // vite() de milpa necesita para emitir <link>/<script> hasheados.
13
+ // La base default se DERIVA de la carpeta: surcos/<app> → /vite/<app>/
14
+ // (la misma convención con la que milpa monta cada app — microfrontends).
15
+ // 2. HOT-FILE (modelo Laravel): `npm run dev` escribe ./hot con la URL real
16
+ // del dev server y lo borra al apagarse; milpa decide dev/prod por su
17
+ // existencia. Un hot-file POR app: tu equipo en dev, los demás en build.
18
+ // 3. PWA opcional (Serwist injectManifest): compila src/sw.js → dist/sw.js
19
+ // ESTÁTICO, precachea el shell del backend, y PREFIJA los entries con la
20
+ // base (gotcha real: salen relativos y 404earían bajo subpath). Solo en
21
+ // build — en dev el SW pelearía con HMR.
22
+ //
23
+ // Nada es mandatorio: cualquier opción se overridea, o no uses el plugin y
24
+ // configura a mano — milpa solo necesita el manifest, la base y el hot-file.
25
+
26
+ import {writeFileSync, rmSync} from 'node:fs';
27
+ import {basename, resolve} from 'node:path';
28
+ import {serwist} from '@serwist/vite';
29
+
30
+ const DEFAULTS = {
31
+ entry: 'src/main.jsx',
32
+ assetsUrl: null, // null => derivada: /vite/<nombre-de-la-carpeta>
33
+ publicDir: '../../public', // raíz public/ del PROYECTO (estilo Laravel): el build
34
+ // de cada surco cae en public/<app>/ y milpa monta
35
+ // public/ completo — relativo a la carpeta de la app.
36
+ hotFile: 'hot',
37
+ pwa: false, // false | true | {swSrc, swDest, shell, ...}
38
+ };
39
+
40
+ export function milpa(userOptions = {}) {
41
+ const options = {...DEFAULTS, ...userOptions};
42
+ const plugins = [configPlugin(options), hotFilePlugin(options)];
43
+ if (options.pwa) plugins.push(...pwaPlugins(options));
44
+ return plugins;
45
+ }
46
+
47
+ // public/<app> de ESTA app (el outDir del build), relativo al root del frontend.
48
+ function resolveOutDir(options, root) {
49
+ const appRoot = resolve(root ?? '.');
50
+ return resolve(appRoot, options.publicDir, basename(appRoot));
51
+ }
52
+
53
+ // La base de la app: explícita, o derivada de la carpeta (surcos/<app> → /vite/<app>/).
54
+ // ASSET_URL (env, solo build) se antepone a la derivada — el MISMO env var que lee
55
+ // el backend milpa en runtime, igual que laravel-vite-plugin: deploy bajo sub-ruta
56
+ // de reverse proxy (ASSET_URL=/nombre-reverse) o CDN, sin tocar el vite.config.
57
+ // Un assetsUrl explícito toma control total (no se le antepone nada).
58
+ function resolveBase(options, root) {
59
+ const assetUrl = (process.env.ASSET_URL ?? '').replace(/\/+$/, '');
60
+ const url = options.assetsUrl ?? `${assetUrl}/vite/${basename(resolve(root ?? '.'))}`;
61
+ return url.endsWith('/') ? url : `${url}/`;
62
+ }
63
+
64
+ function configPlugin(options) {
65
+ return {
66
+ name: 'milpa:config',
67
+ config(config, {command}) {
68
+ return {
69
+ // En dev la base es '/' (los módulos salen del dev server);
70
+ // en build es donde milpa servirá dist/ (los chunks se
71
+ // referencian entre sí con esta base).
72
+ base: command === 'build' ? resolveBase(options, config.root) : '/',
73
+ build: {
74
+ manifest: true,
75
+ rollupOptions: {input: options.entry},
76
+ // El build cae en public/<app> del PROYECTO (como mix.js a
77
+ // public/ en Laravel). emptyOutDir explícito: Vite no limpia
78
+ // solo los outDir fuera del root del frontend.
79
+ outDir: resolveOutDir(options, config.root),
80
+ emptyOutDir: true,
81
+ },
82
+ };
83
+ },
84
+ configResolved(config) {
85
+ // El root REAL queda disponible para los demás sub-plugins (el
86
+ // manifestTransform de la PWA lo necesita y corre fuera de un hook
87
+ // con config a la mano).
88
+ options._root = config.root;
89
+ },
90
+ };
91
+ }
92
+
93
+ function hotFilePlugin(options) {
94
+ let hotPath = options.hotFile;
95
+ return {
96
+ name: 'milpa:hot-file',
97
+ configResolved(config) {
98
+ hotPath = resolve(config.root, options.hotFile);
99
+ },
100
+ configureServer(server) {
101
+ server.httpServer?.once('listening', () => {
102
+ const address = server.httpServer.address();
103
+ const host = typeof address === 'object' && address.address !== '::' && address.address !== '0.0.0.0'
104
+ ? address.address
105
+ : 'localhost';
106
+ writeFileSync(hotPath, `http://${host}:${address.port}`);
107
+ });
108
+ const clean = () => {
109
+ rmSync(hotPath, {force: true});
110
+ process.exit();
111
+ };
112
+ process.once('SIGINT', clean);
113
+ process.once('SIGTERM', clean);
114
+ process.once('exit', () => rmSync(hotPath, {force: true}));
115
+ },
116
+ };
117
+ }
118
+
119
+ function pwaPlugins(options) {
120
+ const pwa = options.pwa === true ? {} : options.pwa;
121
+ const raw = serwist({
122
+ swSrc: pwa.swSrc ?? 'src/sw.js',
123
+ swDest: pwa.swDest ?? 'sw.js',
124
+ // Mismo destino que el build (public/<app>); se calcula desde el cwd del
125
+ // frontend (el gestor corre los scripts con cwd = carpeta de la app).
126
+ globDirectory: pwa.globDirectory ?? resolveOutDir(options, '.'),
127
+ injectionPoint: 'self.__SW_MANIFEST',
128
+ rollupFormat: 'iife',
129
+ // El shell HTML lo sirve el backend (no está en dist/): precachearlo da
130
+ // cold-start offline y el fallback de documentos del sw.js.
131
+ ...(pwa.shell ? {additionalPrecacheEntries: [{url: pwa.shell, revision: `${Date.now()}`}]} : {}),
132
+ // GOTCHA (docs/prerelease/26): los entries salen RELATIVOS y resuelven
133
+ // contra la URL del SW → 404 bajo subpath. Se prefijan con la base real.
134
+ manifestTransforms: [
135
+ (entries) => ({
136
+ manifest: entries.map((entry) => (
137
+ entry.url.startsWith('/') ? entry : {...entry, url: `${resolveBase(options, options._root)}${entry.url}`}
138
+ )),
139
+ }),
140
+ ],
141
+ });
142
+ // El SW solo se genera en BUILD: en dev serviría assets stale y pelearía
143
+ // con HMR. `apply: 'build'` es el switch idiomático de Vite para esto.
144
+ return (Array.isArray(raw) ? raw : [raw]).map((plugin) => ({apply: 'build', ...plugin}));
145
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "vite-plugin-milpa",
3
+ "version": "0.1.0",
4
+ "description": "El toolkit frontend del framework milpa (FastAPI + Jinja), estilo laravel-vite-plugin: hot-file para HMR, manifest para el helper vite() de Jinja, multi-app (surcos/), PWA opcional con Serwist, ASSET_URL para deploy bajo sub-ruta/CDN — y file-based routing para react-router 7 vía `vite-plugin-milpa/router`.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ },
13
+ "./router": {
14
+ "types": "./router.d.ts",
15
+ "default": "./router.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "index.d.ts",
21
+ "router.js",
22
+ "router.d.ts"
23
+ ],
24
+ "keywords": [
25
+ "vite-plugin",
26
+ "vite",
27
+ "milpa",
28
+ "fastapi",
29
+ "jinja",
30
+ "laravel-vite",
31
+ "backend-integration",
32
+ "pwa",
33
+ "serwist",
34
+ "microfrontends",
35
+ "file-based-routing",
36
+ "react-router"
37
+ ],
38
+ "author": "Calcifux (Carlos Guillermo Reyes Ramiro)",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/calcifux/vite-plugin-milpa.git"
43
+ },
44
+ "homepage": "https://github.com/calcifux/vite-plugin-milpa#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/calcifux/vite-plugin-milpa/issues"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "volta": {
52
+ "node": "22.22.2"
53
+ },
54
+ "peerDependencies": {
55
+ "react": ">=18",
56
+ "react-router": ">=7",
57
+ "vite": ">=5"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "react": {
61
+ "optional": true
62
+ },
63
+ "react-router": {
64
+ "optional": true
65
+ }
66
+ },
67
+ "dependencies": {
68
+ "@serwist/vite": "^9.5.11"
69
+ }
70
+ }
package/router.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ // Tipos de vite-plugin-milpa/router (el subpath RUNTIME: file-based routing
2
+ // para react-router 7 en modo library). El fuente es JS plano auditable; los
3
+ // globs los llama el CONSUMIDOR (import.meta.glob es compile-time y resuelve
4
+ // relativo al archivo que lo contiene — no puede vivir en node_modules).
5
+ import type {RouteObject} from 'react-router';
6
+
7
+ /**
8
+ * El mapa que devuelve import.meta.glob SIN eager: archivo → loader perezoso.
9
+ * Cada loader se vuelve un chunk propio (code-splitting por ruta).
10
+ */
11
+ export type GlobMap = Record<string, () => Promise<unknown>>;
12
+
13
+ export interface BuildRoutesGlobs {
14
+ /**
15
+ * Glob de las páginas "core" del shell (carpeta pages/, recursivo).
16
+ * Convención: index → raíz de su carpeta, [id] → :id, prefijo _ → no-ruta.
17
+ */
18
+ pages?: GlobMap;
19
+ /**
20
+ * Glob de las páginas por módulo (modules/<m>/pages/, recursivo): cada
21
+ * módulo se monta bajo /<m>; su _layout (opcional) envuelve sus rutas.
22
+ * Espejo de los Modules/<X>/Http del backend milpa.
23
+ */
24
+ modules?: GlobMap;
25
+ }
26
+
27
+ /**
28
+ * Construye el árbol RouteObject[] de react-router desde tus globs. Llamar sin
29
+ * ninguno truena con instrucción (nunca falla en silencio).
30
+ */
31
+ export declare function buildRoutes(globs?: BuildRoutesGlobs): RouteObject[];
package/router.js ADDED
@@ -0,0 +1,123 @@
1
+ // vite-plugin-milpa/router — file-based routing para react-router 7 en modo
2
+ // LIBRARY (createBrowserRouter), pensado para SPAs servidas por un backend
3
+ // (milpa, Laravel, Django). Es el subpath RUNTIME del paquete: el plugin
4
+ // (index.js) corre en Node al buildear; esto se bundlea DENTRO de tu app
5
+ // (mismo patrón que @serwist/vite + @serwist/vite/worker).
6
+ //
7
+ // Convención (ESPEJO del backend milpa, que auto-monta Modules/<X>/Http):
8
+ //
9
+ // pages/**/* → rutas "core" del shell /acerca
10
+ // modules/<m>/pages/**/* → rutas del módulo <m> /<m>/...
11
+ //
12
+ // index.* → la raíz de su carpeta ('' como segmento)
13
+ // [id].* → segmento dinámico :id (useParams() en la página)
14
+ // _layout.* → layout del módulo (envuelve sus rutas; opcional)
15
+ // _otro.* → ignorado (prefijo '_' = no-ruta, como un parcial)
16
+ //
17
+ // 10 devs = 10 carpetas en modules/: cada quien dropea páginas en SU módulo y
18
+ // la ruta existe — nadie toca un router central ni pisa al vecino.
19
+ //
20
+ // POR QUÉ recibe los globs en vez de llamarlos (la decisión clave del paquete):
21
+ // import.meta.glob es compile-time de Vite y resuelve RELATIVO al archivo que lo
22
+ // contiene — si viviera aquí (node_modules) globearía este paquete, no tus pages
23
+ // (y Vite ni transforma globs cuyo importer está en node_modules; issue #2390).
24
+ // El glob va en TU código; aquí solo se interpreta el mapa {archivo: loader}:
25
+ //
26
+ // import {buildRoutes} from 'vite-plugin-milpa/router';
27
+ // const routes = buildRoutes({
28
+ // pages: import.meta.glob('./pages/**/*.jsx'),
29
+ // modules: import.meta.glob('./modules/*/pages/**/*.jsx'),
30
+ // });
31
+ //
32
+ // Globs SIN eager devuelven () => import(...): cada página es un chunk propio
33
+ // (code-splitting por ruta GRATIS, como el App Router de Next). Bonus: como el
34
+ // glob es tuyo, este módulo NO depende de Vite — sirve cualquier bundler que
35
+ // produzca el mismo mapa. JS plano sin JSX a propósito: createElement es el
36
+ // equivalente compilado de <X/> y los bundlers NO parsean JSX en .js de
37
+ // node_modules (esbuild: JSX solo en .jsx/.tsx; vite issue #8954).
38
+
39
+ import {createElement, lazy} from 'react';
40
+
41
+ // 'productos/[id].jsx' → 'productos/:id' · 'index.jsx' → '' · acepta .jsx/.tsx/.js/.ts
42
+ function toPath(relativeFile) {
43
+ return relativeFile
44
+ .replace(/\.(jsx|tsx|js|ts)$/, '')
45
+ .split('/')
46
+ .map((segment) => (segment === 'index' ? '' : segment.replace(/^\[(.+)\]$/, ':$1')))
47
+ .filter(Boolean)
48
+ .join('/');
49
+ }
50
+
51
+ function lazyElement(loader) {
52
+ return createElement(lazy(loader));
53
+ }
54
+
55
+ function toRoute(path, loader) {
56
+ return path === '' ? {index: true, element: lazyElement(loader)} : {path, element: lazyElement(loader)};
57
+ }
58
+
59
+ // Basename con prefijo '_' = no-ruta (el _layout de módulo se trata aparte).
60
+ function isHidden(relativeFile) {
61
+ return relativeFile.split('/').pop().startsWith('_');
62
+ }
63
+
64
+ const PAGES_RE = /\/pages\/(.+)$/;
65
+ const MODULE_RE = /\/modules\/([^/]+)\/pages\/(.+)$/;
66
+ const LAYOUT_RE = /^_layout\.(jsx|tsx|js|ts)$/;
67
+
68
+ /**
69
+ * Construye el árbol de rutas (RouteObject[] de react-router) desde los mapas
70
+ * de import.meta.glob del CONSUMIDOR. Ambas claves son opcionales, pero llamar
71
+ * sin ninguna truena con instrucción (tenet milpa: nunca fallar en silencio).
72
+ */
73
+ export function buildRoutes({pages, modules} = {}) {
74
+ if (pages === undefined && modules === undefined) {
75
+ throw new Error(
76
+ 'buildRoutes necesita tus globs — en TU código (import.meta.glob resuelve relativo ' +
77
+ "al archivo que lo contiene): buildRoutes({pages: import.meta.glob('./pages/**/*.jsx'), " +
78
+ "modules: import.meta.glob('./modules/*/pages/**/*.jsx')}).",
79
+ );
80
+ }
81
+ const routes = [];
82
+
83
+ // Páginas core del shell (las del equipo "plataforma").
84
+ for (const [file, loader] of Object.entries(pages ?? {})) {
85
+ const match = file.match(PAGES_RE);
86
+ if (!match) {
87
+ throw new Error(`'${file}' no sigue la convención pages/ — ¿el glob de \`pages\` apunta a otra carpeta?`);
88
+ }
89
+ if (isHidden(match[1])) continue;
90
+ routes.push(toRoute(toPath(match[1]), loader));
91
+ }
92
+
93
+ // Páginas por módulo, agrupadas y montadas bajo /<modulo>.
94
+ const byModule = new Map();
95
+ const layouts = new Map();
96
+ for (const [file, loader] of Object.entries(modules ?? {})) {
97
+ const match = file.match(MODULE_RE);
98
+ if (!match) {
99
+ throw new Error(
100
+ `'${file}' no sigue la convención modules/<m>/pages/ — ¿el glob de \`modules\` apunta a otra carpeta?`,
101
+ );
102
+ }
103
+ const [, moduleName, relativeFile] = match;
104
+ if (LAYOUT_RE.test(relativeFile)) {
105
+ layouts.set(moduleName, loader); // no es página: es el wrapper del módulo
106
+ continue;
107
+ }
108
+ if (isHidden(relativeFile)) continue;
109
+ if (!byModule.has(moduleName)) byModule.set(moduleName, []);
110
+ byModule.get(moduleName).push(toRoute(toPath(relativeFile), loader));
111
+ }
112
+ for (const [moduleName, children] of byModule) {
113
+ const layoutLoader = layouts.get(moduleName);
114
+ routes.push({
115
+ path: moduleName,
116
+ // Sin _layout, react-router renderiza los hijos directo (<Outlet/> implícito).
117
+ ...(layoutLoader ? {element: lazyElement(layoutLoader)} : {}),
118
+ children,
119
+ });
120
+ }
121
+
122
+ return routes;
123
+ }