@ecomiq/storefront 1.1.5 → 2.0.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.
Files changed (134) hide show
  1. package/README.md +26 -417
  2. package/bin/ecomiq.mjs +154 -0
  3. package/package.json +72 -84
  4. package/src/api-proxy.ts +194 -0
  5. package/src/auth/create-storefront-auth.ts +35 -0
  6. package/src/auth/ecomiq-oauth-password-client.ts +26 -0
  7. package/src/auth/ecomiq-oauth-password-constants.ts +26 -0
  8. package/src/auth/ecomiq-oauth-password-plugin.ts +299 -0
  9. package/src/auth/ecomiq-oauth-password-types.ts +25 -0
  10. package/src/auth/ecomiq-storefront-sign-in.ts +47 -0
  11. package/src/auth/env.ts +7 -0
  12. package/src/cart-session.ts +241 -0
  13. package/src/client.ts +48 -0
  14. package/src/codegen.mjs +603 -0
  15. package/src/components/checkout/cart-sync.ts +143 -0
  16. package/src/components/checkout/checkout-external-update-banner.tsx +15 -0
  17. package/src/components/checkout/checkout-form-controls.tsx +139 -0
  18. package/src/components/checkout/checkout-line-items.tsx +43 -0
  19. package/src/components/checkout/checkout-order-summary.tsx +156 -0
  20. package/src/components/checkout/checkout-payment-section.tsx +215 -0
  21. package/src/components/checkout/checkout-session-view.tsx +240 -0
  22. package/src/components/checkout/checkout-shipping-address-section.tsx +266 -0
  23. package/src/components/checkout/checkout-shipping-section.tsx +90 -0
  24. package/src/components/checkout/checkout-states.tsx +73 -0
  25. package/src/components/checkout/form-schema.ts +229 -0
  26. package/src/components/checkout/format.ts +27 -0
  27. package/src/components/checkout/index.ts +51 -0
  28. package/src/components/checkout/types.ts +43 -0
  29. package/src/components/checkout/use-checkout-geo.ts +50 -0
  30. package/src/components/checkout/use-checkout-session.ts +583 -0
  31. package/src/components/hero.tsx +291 -0
  32. package/src/config.d.ts +69 -0
  33. package/src/config.mjs +165 -0
  34. package/src/index.ts +274 -0
  35. package/src/link.tsx +2 -0
  36. package/src/pages/checkout.$checkoutId.tsx +60 -0
  37. package/src/pages/checkout.tsx +31 -0
  38. package/src/pages/thank-you.tsx +53 -0
  39. package/src/providers/auth.tsx +69 -0
  40. package/src/providers/cart.tsx +275 -0
  41. package/src/providers/ecomiq.ts +137 -0
  42. package/src/providers/index.ts +26 -0
  43. package/src/public-config.ts +44 -0
  44. package/src/review-submission.ts +89 -0
  45. package/src/runtime/context.ts +1 -0
  46. package/src/runtime/default-layout.tsx +80 -0
  47. package/src/runtime/head.ts +126 -0
  48. package/src/runtime/page.tsx +82 -0
  49. package/src/runtime/postcss-browser-stub.mjs +5 -0
  50. package/src/server-config-store.ts +26 -0
  51. package/src/server.ts +279 -0
  52. package/src/styles.css +76 -0
  53. package/src/ui/badge.tsx +32 -0
  54. package/src/ui/button.tsx +31 -0
  55. package/src/ui/card.tsx +31 -0
  56. package/src/ui/carousel.tsx +218 -0
  57. package/src/ui/cn.ts +6 -0
  58. package/src/ui/index.ts +8 -0
  59. package/src/ui/separator.tsx +6 -0
  60. package/src/ui/skeleton.tsx +6 -0
  61. package/src/ui/storefront-error.tsx +49 -0
  62. package/src/utils.ts +36 -0
  63. package/src/vite-inline.mjs +95 -0
  64. package/templates/__root.tsx +77 -0
  65. package/templates/router.tsx +19 -0
  66. package/dist/chunk-3QGB55R6.js +0 -157
  67. package/dist/chunk-3QGB55R6.js.map +0 -1
  68. package/dist/chunk-64GLOWMJ.js +0 -216
  69. package/dist/chunk-64GLOWMJ.js.map +0 -1
  70. package/dist/chunk-ASFU32ZM.js +0 -236
  71. package/dist/chunk-ASFU32ZM.js.map +0 -1
  72. package/dist/chunk-Q4U5OEUT.js +0 -21
  73. package/dist/chunk-Q4U5OEUT.js.map +0 -1
  74. package/dist/chunk-QRR7EPCH.js +0 -176
  75. package/dist/chunk-QRR7EPCH.js.map +0 -1
  76. package/dist/chunk-S7CPZ4UE.js +0 -6
  77. package/dist/chunk-S7CPZ4UE.js.map +0 -1
  78. package/dist/chunk-ZG2XUYGZ.js +0 -1281
  79. package/dist/chunk-ZG2XUYGZ.js.map +0 -1
  80. package/dist/chunks/client.css +0 -984
  81. package/dist/chunks/client.css.map +0 -1
  82. package/dist/chunks/client.d.ts +0 -10041
  83. package/dist/chunks/client.js +0 -5363
  84. package/dist/chunks/client.js.map +0 -1
  85. package/dist/chunks/config.d.ts +0 -31
  86. package/dist/chunks/config.js +0 -5
  87. package/dist/chunks/config.js.map +0 -1
  88. package/dist/chunks/core-public.d.ts +0 -130
  89. package/dist/chunks/core-public.js +0 -92
  90. package/dist/chunks/core-public.js.map +0 -1
  91. package/dist/chunks/server-nextjs.d.ts +0 -33
  92. package/dist/chunks/server-nextjs.js +0 -47
  93. package/dist/chunks/server-nextjs.js.map +0 -1
  94. package/dist/chunks/server-tanstack.d.ts +0 -22
  95. package/dist/chunks/server-tanstack.js +0 -34
  96. package/dist/chunks/server-tanstack.js.map +0 -1
  97. package/dist/collection-C6bWDLiw.d.ts +0 -3926
  98. package/dist/ecomiq-oauth-password-plugin-J5wDFqzL.d.ts +0 -3629
  99. package/dist/ecomiq-shell-ui-SWIFLQVM.js +0 -8
  100. package/dist/ecomiq-shell-ui-SWIFLQVM.js.map +0 -1
  101. package/dist/index.d.ts +0 -9
  102. package/dist/index.js +0 -1
  103. package/dist/index.js.map +0 -1
  104. package/dist/index.server.d.ts +0 -9
  105. package/dist/index.server.js +0 -8
  106. package/dist/index.server.js.map +0 -1
  107. package/dist/nextjs/api.d.ts +0 -45
  108. package/dist/nextjs/api.js +0 -32
  109. package/dist/nextjs/api.js.map +0 -1
  110. package/dist/nextjs/globals.css +0 -13
  111. package/dist/nextjs.d.ts +0 -2
  112. package/dist/nextjs.js +0 -2
  113. package/dist/nextjs.js.map +0 -1
  114. package/dist/nextjs.server.d.ts +0 -1
  115. package/dist/nextjs.server.js +0 -1
  116. package/dist/nextjs.server.js.map +0 -1
  117. package/dist/postcss.config.mjs +0 -6
  118. package/dist/react/checkout-page.css +0 -855
  119. package/dist/react/client.css +0 -984
  120. package/dist/react/payment-provider.css +0 -91
  121. package/dist/react/thank-you-page.css +0 -105
  122. package/dist/react.d.ts +0 -1
  123. package/dist/react.js +0 -2
  124. package/dist/react.js.map +0 -1
  125. package/dist/store-proxy-handler-CNzKlTFA.d.ts +0 -10
  126. package/dist/tanstack/api.d.ts +0 -41
  127. package/dist/tanstack/api.js +0 -30
  128. package/dist/tanstack/api.js.map +0 -1
  129. package/dist/tanstack.d.ts +0 -2
  130. package/dist/tanstack.js +0 -2
  131. package/dist/tanstack.js.map +0 -1
  132. package/dist/tanstack.server.d.ts +0 -1
  133. package/dist/tanstack.server.js +0 -1
  134. package/dist/tanstack.server.js.map +0 -1
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Codegen de Ecomiq.
3
+ *
4
+ * Lee `pages/` y `ecomiq.config.js` del proyecto del usuario y genera una app
5
+ * interna en `.ecomiq/` que sigue las convenciones de TanStack Start
6
+ * (routes con createFileRoute, router.tsx, vite.config.ts). El usuario nunca
7
+ * ve ni edita estos archivos: son un artefacto de build, como `.next/`.
8
+ *
9
+ * Convención de páginas:
10
+ * pages/index.tsx → /
11
+ * pages/products.$productId.tsx → /products/:productId
12
+ * pages/products.index.tsx → /products
13
+ * pages/api/$.ts → /api/$ (server route / proxy)
14
+ *
15
+ * Páginas cerradas del framework (siempre generadas, no overrideables):
16
+ * /checkout/ ← redirect a /cart (checkout.index, sin layout padre)
17
+ * /checkout/$checkoutId ← flujo de checkout
18
+ * /thank-you ← post-compra
19
+ */
20
+ import {
21
+ mkdirSync,
22
+ readdirSync,
23
+ readFileSync,
24
+ rmSync,
25
+ watch as watchFileSystem,
26
+ writeFileSync,
27
+ existsSync,
28
+ symlinkSync,
29
+ lstatSync,
30
+ readlinkSync,
31
+ unlinkSync,
32
+ statSync,
33
+ } from 'node:fs'
34
+ import { resolve, join, relative, dirname, isAbsolute, sep } from 'node:path'
35
+
36
+ const HEADER = '/* Generado por Ecomiq — no editar. */\n'
37
+ const ROUTE_MANIFEST_FILE = 'generated-routes.json'
38
+
39
+ /** Rutas reservadas que viven solo en el package (templates no pueden overridear). */
40
+ const FRAMEWORK_LOCKED_PAGES = [
41
+ {
42
+ // index (no `checkout.tsx`): evita que TanStack anide $checkoutId bajo un layout
43
+ // que redirige a /cart.
44
+ rel: 'checkout.index.tsx',
45
+ routeId: '/checkout/',
46
+ importFrom: '@ecomiq/storefront/internal/checkout',
47
+ },
48
+ {
49
+ rel: 'checkout.$checkoutId.tsx',
50
+ routeId: '/checkout/$checkoutId',
51
+ importFrom: '@ecomiq/storefront/internal/checkout-session',
52
+ },
53
+ {
54
+ rel: 'thank-you.tsx',
55
+ routeId: '/thank-you',
56
+ importFrom: '@ecomiq/storefront/internal/thank-you',
57
+ },
58
+ ]
59
+
60
+ export async function generateApp({ cwd, appDir, frameworkRoot, config }) {
61
+ const pagesDir = resolve(cwd, 'pages')
62
+ if (!existsSync(pagesDir)) {
63
+ throw new Error('No existe la carpeta pages/. Crea al menos pages/index.tsx')
64
+ }
65
+
66
+ const srcDir = join(appDir, 'src')
67
+ const routesDir = join(srcDir, 'routes')
68
+ mkdirSync(routesDir, { recursive: true })
69
+
70
+ // ── 1. dependencias internas resolubles desde .ecomiq (soporta npm, pnpm y monorepos) ──
71
+ linkFrameworkDependencies(appDir, frameworkRoot)
72
+
73
+ // ── 2. package.json interno (type: module para que Vite resuelva bien) ──
74
+ writeGeneratedFile(
75
+ join(appDir, 'package.json'),
76
+ JSON.stringify({ name: 'ecomiq-app', private: true, type: 'module' }, null, 2),
77
+ )
78
+
79
+ // ── 3. tsconfig interno ──
80
+ writeGeneratedFile(
81
+ join(appDir, 'tsconfig.json'),
82
+ JSON.stringify(
83
+ {
84
+ compilerOptions: {
85
+ jsx: 'react-jsx',
86
+ module: 'ESNext',
87
+ moduleResolution: 'Bundler',
88
+ target: 'ES2022',
89
+ strict: true,
90
+ skipLibCheck: true,
91
+ verbatimModuleSyntax: true,
92
+ types: ['vite/client'],
93
+ },
94
+ include: ['src', '../pages', '../lib'],
95
+ },
96
+ null,
97
+ 2,
98
+ ),
99
+ )
100
+
101
+ const publicConfig = {
102
+ name: config.name,
103
+ slug: config.slug,
104
+ }
105
+ if (config.description !== undefined) publicConfig.description = config.description
106
+ if (config.theme !== undefined) publicConfig.theme = config.theme
107
+ if (config.seo !== undefined) publicConfig.seo = config.seo
108
+ if (config.analytics !== undefined) publicConfig.analytics = config.analytics
109
+ if (config.layout !== undefined) publicConfig.layout = config.layout
110
+
111
+ for (const serverOnlyKey of ['api', 'dev', 'deploy', 'env']) {
112
+ if (Object.hasOwn(publicConfig, serverOnlyKey)) {
113
+ throw new Error(`La clave server-only \`${serverOnlyKey}\` no puede serializarse en app-config.ts`)
114
+ }
115
+ }
116
+
117
+ writeGeneratedFile(
118
+ join(srcDir, 'app-config.ts'),
119
+ HEADER +
120
+ `import type { PublicEcomiqConfig } from '@ecomiq/storefront'\n\n` +
121
+ `const config = ${JSON.stringify(publicConfig, null, 2)} satisfies PublicEcomiqConfig\n` +
122
+ `export default config\n`,
123
+ )
124
+
125
+ writeGeneratedFile(
126
+ join(srcDir, 'server.ts'),
127
+ HEADER +
128
+ `import startServer, { createServerEntry } from '@tanstack/react-start/server-entry'\n` +
129
+ `import config from '../../ecomiq.config.js'\n` +
130
+ `import { registerServerConfig } from '@ecomiq/storefront/internal/server-config'\n\n` +
131
+ `export default createServerEntry({\n` +
132
+ ` async fetch(...args) {\n` +
133
+ ` let api = config.api\n` +
134
+ ` if (import.meta.env.DEV && config.dev?.apiBaseUrl) {\n` +
135
+ ` api = { ...api, baseUrl: config.dev.apiBaseUrl }\n` +
136
+ ` }\n` +
137
+ ` registerServerConfig({ slug: config.slug, api })\n` +
138
+ ` return startServer.fetch(...args)\n` +
139
+ ` },\n` +
140
+ `})\n`,
141
+ )
142
+
143
+ // ── 5. styles.css: tokens del framework + fuentes de contenido del usuario ──
144
+ const themeOverrides = buildThemeOverrides(config)
145
+ writeGeneratedFile(
146
+ join(srcDir, 'styles.css'),
147
+ HEADER.replace('/*', '/*!') +
148
+ `@import '@ecomiq/storefront/styles.css';\n` +
149
+ `@source '../../pages';\n` +
150
+ `@source '../../components';\n` +
151
+ `@source '../../lib';\n` +
152
+ `@source '../../layout.tsx';\n` +
153
+ (themeOverrides ? `\n${themeOverrides}\n` : ''),
154
+ )
155
+
156
+ // ── 6. router.tsx ──
157
+ writeTemplate(frameworkRoot, srcDir, 'router.tsx')
158
+
159
+ // ── 7. __root.tsx (shell HTML + providers) ──
160
+ writeTemplate(frameworkRoot, routesDir, '__root.tsx')
161
+
162
+ // ── 7b. user-layout.tsx: layout.tsx del usuario si existe, si no el default ──
163
+ const hasCustomLayout = existsSync(resolve(cwd, 'layout.tsx')) || existsSync(resolve(cwd, 'layout.jsx'))
164
+ writeGeneratedFile(
165
+ join(srcDir, 'user-layout.tsx'),
166
+ HEADER +
167
+ (hasCustomLayout
168
+ ? `export { default } from '../../layout'\n`
169
+ : `export { default } from '@ecomiq/storefront/internal/default-layout'\n`),
170
+ )
171
+
172
+ // ── 8. rutas generadas desde pages/ (páginas UI + API server routes) ──
173
+ const entries = listPageEntries(pagesDir)
174
+ const pageEntries = entries.filter((e) => e.kind === 'page')
175
+ const apiEntries = entries.filter((e) => e.kind === 'api')
176
+ if (pageEntries.length === 0) {
177
+ throw new Error('pages/ está vacío. Crea al menos pages/index.tsx')
178
+ }
179
+
180
+ assertNoFrameworkPageOverrides(pageEntries)
181
+
182
+ const desiredRoutes = [
183
+ ...pageEntries.map((entry) => entry.rel.replace(/\.jsx$/, '.tsx')),
184
+ ...apiEntries.map((entry) => entry.rel),
185
+ ...FRAMEWORK_LOCKED_PAGES.map((page) => page.rel),
186
+ ].sort()
187
+
188
+ for (const entry of pageEntries) {
189
+ const routeId = fileToRouteId(entry.rel)
190
+ const outFile = join(routesDir, entry.rel.replace(/\.jsx$/, '.tsx'))
191
+ mkdirSync(dirname(outFile), { recursive: true })
192
+ const importPath = relative(dirname(outFile), join(pagesDir, entry.rel))
193
+ .replace(/\\/g, '/')
194
+ .replace(/\.(tsx|jsx)$/, '')
195
+ writeGeneratedFile(outFile, routeFileSource(routeId, importPath, entry.rel))
196
+ }
197
+
198
+ for (const entry of apiEntries) {
199
+ const routeId = fileToRouteId(entry.rel)
200
+ const outFile = join(routesDir, entry.rel)
201
+ mkdirSync(dirname(outFile), { recursive: true })
202
+ // Extensión explícita: archivos como `$.ts` no resuelven bien sin ella.
203
+ const importPath = relative(dirname(outFile), join(pagesDir, entry.rel)).replace(/\\/g, '/')
204
+ writeGeneratedFile(outFile, apiRouteFileSource(routeId, importPath))
205
+ }
206
+
207
+ for (const locked of FRAMEWORK_LOCKED_PAGES) {
208
+ const outFile = join(routesDir, locked.rel)
209
+ mkdirSync(dirname(outFile), { recursive: true })
210
+ writeGeneratedFile(
211
+ outFile,
212
+ frameworkLockedPageRouteSource(locked.routeId, locked.importFrom, locked.rel),
213
+ )
214
+ }
215
+
216
+ reconcileGeneratedRoutes(appDir, routesDir, desiredRoutes)
217
+
218
+ // ── 9. wrangler.jsonc para deploy en Cloudflare Workers ──
219
+ const workerName = config.deploy?.name ?? slugify(config.name ?? 'ecomiq-store')
220
+ writeGeneratedFile(
221
+ join(appDir, 'wrangler.jsonc'),
222
+ JSON.stringify(
223
+ {
224
+ $schema: 'node_modules/wrangler/config-schema.json',
225
+ name: workerName,
226
+ main: 'src/server.ts',
227
+ compatibility_date: '2026-01-01',
228
+ compatibility_flags: ['nodejs_compat'],
229
+ observability: {
230
+ enabled: false,
231
+ head_sampling_rate: 1,
232
+ logs: {
233
+ enabled: true,
234
+ head_sampling_rate: 1,
235
+ persist: true,
236
+ invocation_logs: true,
237
+ },
238
+ traces: {
239
+ enabled: false,
240
+ persist: true,
241
+ head_sampling_rate: 1,
242
+ },
243
+ },
244
+ ...(config.deploy?.routes ? { routes: config.deploy.routes } : {}),
245
+ vars: config.env ?? {},
246
+ },
247
+ null,
248
+ 2,
249
+ ),
250
+ )
251
+
252
+ // ── 10. .gitignore para que .ecomiq no se versione dentro de sí mismo ──
253
+ writeGeneratedFile(join(appDir, '.gitignore'), '*\n')
254
+ }
255
+
256
+ export function watchPages({
257
+ cwd,
258
+ appDir,
259
+ frameworkRoot,
260
+ config,
261
+ debounceMs = 75,
262
+ /** Intervalo de polling (ms) como respaldo cuando `fs.watch` no emite eventos (p. ej. sandbox). */
263
+ pollMs = 0,
264
+ onGenerate = () => {},
265
+ onError = () => {},
266
+ }) {
267
+ const pagesDir = resolve(cwd, 'pages')
268
+ if (!existsSync(pagesDir)) {
269
+ throw new Error('No existe la carpeta pages/. Crea al menos pages/index.tsx')
270
+ }
271
+
272
+ let closed = false
273
+ let dirty = false
274
+ let running = false
275
+ let timer
276
+ let lastPagesSignature = pagesDirSignature(pagesDir)
277
+
278
+ async function flush() {
279
+ if (running || closed) return
280
+ running = true
281
+
282
+ while (dirty && !closed) {
283
+ dirty = false
284
+ try {
285
+ await generateApp({ cwd, appDir, frameworkRoot, config })
286
+ lastPagesSignature = pagesDirSignature(pagesDir)
287
+ onGenerate()
288
+ } catch (error) {
289
+ onError(error instanceof Error ? error : new Error(String(error)))
290
+ }
291
+ }
292
+
293
+ running = false
294
+ }
295
+
296
+ function schedule() {
297
+ if (closed) return
298
+ if (timer) clearTimeout(timer)
299
+ timer = setTimeout(() => {
300
+ timer = undefined
301
+ dirty = true
302
+ void flush()
303
+ }, debounceMs)
304
+ }
305
+
306
+ const watcher = watchFileSystem(pagesDir, { recursive: true }, schedule)
307
+ watcher.on('error', (error) => onError(error))
308
+
309
+ let pollTimer
310
+ if (pollMs > 0) {
311
+ pollTimer = setInterval(() => {
312
+ if (closed) return
313
+ const next = pagesDirSignature(pagesDir)
314
+ if (next !== lastPagesSignature) {
315
+ lastPagesSignature = next
316
+ schedule()
317
+ }
318
+ }, pollMs)
319
+ }
320
+
321
+ return {
322
+ close() {
323
+ closed = true
324
+ dirty = false
325
+ if (timer) clearTimeout(timer)
326
+ if (pollTimer) clearInterval(pollTimer)
327
+ watcher.close()
328
+ },
329
+ }
330
+ }
331
+
332
+ /** Firma ligera de `pages/` para detectar altas/bajas/renombres sin depender de `fs.watch`. */
333
+ function pagesDirSignature(pagesDir) {
334
+ const parts = []
335
+ for (const entry of listPageEntries(pagesDir)) {
336
+ const full = join(pagesDir, entry.rel)
337
+ parts.push(`${entry.rel}:${statSync(full).mtimeMs}`)
338
+ }
339
+ return parts.sort().join('|')
340
+ }
341
+
342
+ /** Lista pages UI (.tsx/.jsx) y API server routes (.ts) de forma recursiva. */
343
+ function listPageEntries(pagesDir, prefix = '') {
344
+ const entries = []
345
+ const dir = prefix ? join(pagesDir, prefix) : pagesDir
346
+ for (const name of readdirSync(dir).sort()) {
347
+ if (
348
+ name.startsWith('.') ||
349
+ name === '__tests__' ||
350
+ /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(name)
351
+ ) continue
352
+ const rel = prefix ? `${prefix}/${name}` : name
353
+ const full = join(pagesDir, rel)
354
+ if (statSync(full).isDirectory()) {
355
+ entries.push(...listPageEntries(pagesDir, rel))
356
+ continue
357
+ }
358
+ if (/\.(tsx|jsx)$/.test(name)) entries.push({ kind: 'page', rel })
359
+ else if (/\.ts$/.test(name) && !name.endsWith('.d.ts')) entries.push({ kind: 'api', rel })
360
+ }
361
+ return entries
362
+ }
363
+
364
+ function reconcileGeneratedRoutes(appDir, routesDir, desiredRoutes) {
365
+ const manifestPath = join(appDir, ROUTE_MANIFEST_FILE)
366
+ const previousRoutes = readRouteManifest(manifestPath)
367
+ const desired = new Set(desiredRoutes)
368
+
369
+ for (const relativePath of previousRoutes) {
370
+ if (desired.has(relativePath)) continue
371
+ const outputPath = ownedRoutePath(routesDir, relativePath)
372
+ if (outputPath) rmSync(outputPath, { force: true })
373
+ }
374
+
375
+ writeGeneratedFile(
376
+ manifestPath,
377
+ JSON.stringify({ version: 1, routes: desiredRoutes }, null, 2) + '\n',
378
+ )
379
+ }
380
+
381
+ function readRouteManifest(manifestPath) {
382
+ if (!existsSync(manifestPath)) return []
383
+
384
+ try {
385
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
386
+ if (manifest?.version !== 1 || !Array.isArray(manifest.routes)) return []
387
+ return manifest.routes.filter((route) => typeof route === 'string')
388
+ } catch {
389
+ return []
390
+ }
391
+ }
392
+
393
+ function ownedRoutePath(routesDir, relativePath) {
394
+ if (
395
+ relativePath.length === 0 ||
396
+ isAbsolute(relativePath) ||
397
+ relativePath.split('/').includes('..')
398
+ ) {
399
+ return null
400
+ }
401
+ const outputPath = resolve(routesDir, relativePath)
402
+ if (!outputPath.startsWith(resolve(routesDir) + sep)) return null
403
+ return outputPath
404
+ }
405
+
406
+ /**
407
+ * 'products.$productId.tsx' → '/products/$productId'
408
+ * 'api/$.ts' → '/api/$'
409
+ * 'index.tsx' → '/'
410
+ */
411
+ function fileToRouteId(relPath) {
412
+ const withoutExt = relPath.replace(/\.(tsx|jsx|ts)$/, '')
413
+ if (withoutExt === 'index') return '/'
414
+
415
+ const segments = []
416
+ for (const part of withoutExt.split('/')) {
417
+ const bits = part.split('.').map((s) => (s === 'index' ? '' : s))
418
+ segments.push(...bits.filter(Boolean))
419
+ }
420
+ return '/' + segments.join('/') + (withoutExt.endsWith('.index') ? '/' : '')
421
+ }
422
+
423
+ /** Relativo desde routes/<rel> hasta src/app-config. */
424
+ function appConfigImportFrom(relPath) {
425
+ const nestDepth = relPath.split('/').length - 1
426
+ return '../'.repeat(nestDepth + 1) + 'app-config'
427
+ }
428
+
429
+ /** Código de una ruta interna que envuelve la página del usuario. */
430
+ function routeFileSource(routeId, importPath, relPath) {
431
+ const appConfigImport = appConfigImportFrom(relPath)
432
+
433
+ return (
434
+ HEADER +
435
+ `import { createFileRoute } from '@tanstack/react-router'\n` +
436
+ `import appConfig from '${appConfigImport}'\n` +
437
+ `import pageModule from '${importPath}'\n` +
438
+ `import { pageToRouteOptions, PageBoundary } from '@ecomiq/storefront/internal/page'\n\n` +
439
+ `export const Route = createFileRoute('${routeId}')({\n` +
440
+ ` ...pageToRouteOptions(pageModule, appConfig),\n` +
441
+ ` component: PageComponent,\n` +
442
+ `})\n\n` +
443
+ `function PageComponent() {\n` +
444
+ ` const data = Route.useLoaderData()\n` +
445
+ ` const params = Route.useParams()\n` +
446
+ ` const search = Route.useSearch()\n` +
447
+ ` return <PageBoundary page={pageModule} data={data} params={params} search={search} />\n` +
448
+ `}\n`
449
+ )
450
+ }
451
+
452
+ /** Página cerrada del framework (import desde package, no desde pages/ del usuario). */
453
+ function frameworkLockedPageRouteSource(routeId, importFrom, relPath) {
454
+ const appConfigImport = appConfigImportFrom(relPath)
455
+
456
+ return (
457
+ HEADER +
458
+ `import { createFileRoute } from '@tanstack/react-router'\n` +
459
+ `import appConfig from '${appConfigImport}'\n` +
460
+ `import pageModule from '${importFrom}'\n` +
461
+ `import { pageToRouteOptions, PageBoundary } from '@ecomiq/storefront/internal/page'\n\n` +
462
+ `export const Route = createFileRoute('${routeId}')({\n` +
463
+ ` ...pageToRouteOptions(pageModule, appConfig),\n` +
464
+ ` component: PageComponent,\n` +
465
+ `})\n\n` +
466
+ `function PageComponent() {\n` +
467
+ ` const data = Route.useLoaderData()\n` +
468
+ ` const params = Route.useParams()\n` +
469
+ ` const search = Route.useSearch()\n` +
470
+ ` return <PageBoundary page={pageModule} data={data} params={params} search={search} />\n` +
471
+ `}\n`
472
+ )
473
+ }
474
+
475
+ function assertNoFrameworkPageOverrides(pageEntries) {
476
+ const reserved = new Map(
477
+ FRAMEWORK_LOCKED_PAGES.map((page) => [page.routeId, page.rel]),
478
+ )
479
+ // Overrides / alias legados
480
+ reserved.set('/checkout', 'checkout.tsx')
481
+ reserved.set('/checkout/$cartId', 'checkout.$cartId.tsx')
482
+
483
+ for (const entry of pageEntries) {
484
+ const routeId = fileToRouteId(entry.rel)
485
+ if (!reserved.has(routeId)) continue
486
+ throw new Error(
487
+ `La ruta ${routeId} es reservada por Ecomiq (página cerrada). ` +
488
+ `Elimina pages/${entry.rel}; las páginas de checkout y thank-you viven en @ecomiq/storefront.`,
489
+ )
490
+ }
491
+ }
492
+
493
+ /** Server route: reexporta GET/POST/... del módulo del usuario. */
494
+ function apiRouteFileSource(routeId, importPath) {
495
+ return (
496
+ HEADER +
497
+ `import { createFileRoute } from '@tanstack/react-router'\n` +
498
+ `import * as api from '${importPath}'\n\n` +
499
+ `export const Route = createFileRoute('${routeId}')({\n` +
500
+ ` server: {\n` +
501
+ ` handlers: {\n` +
502
+ ` ...(api.GET ? { GET: api.GET } : {}),\n` +
503
+ ` ...(api.POST ? { POST: api.POST } : {}),\n` +
504
+ ` ...(api.PUT ? { PUT: api.PUT } : {}),\n` +
505
+ ` ...(api.PATCH ? { PATCH: api.PATCH } : {}),\n` +
506
+ ` ...(api.DELETE ? { DELETE: api.DELETE } : {}),\n` +
507
+ ` },\n` +
508
+ ` },\n` +
509
+ `})\n`
510
+ )
511
+ }
512
+
513
+ function buildThemeOverrides(config) {
514
+ const theme = config.theme ?? {}
515
+ const map = {
516
+ primary: '--primary',
517
+ primaryForeground: '--primary-foreground',
518
+ radius: '--radius',
519
+ background: '--background',
520
+ foreground: '--foreground',
521
+ accent: '--accent',
522
+ }
523
+ const lines = Object.entries(map)
524
+ .filter(([key]) => theme[key])
525
+ .map(([key, cssVar]) => ` ${cssVar}: ${theme[key]};`)
526
+ if (lines.length === 0) return ''
527
+ return `:root {\n${lines.join('\n')}\n}`
528
+ }
529
+
530
+ function writeTemplate(frameworkRoot, targetDir, name) {
531
+ const src = resolve(frameworkRoot, 'templates', name)
532
+ writeGeneratedFile(join(targetDir, name), readFileSync(src, 'utf8'))
533
+ }
534
+
535
+ function writeGeneratedFile(path, contents) {
536
+ if (existsSync(path) && readFileSync(path, 'utf8') === contents) return
537
+ mkdirSync(dirname(path), { recursive: true })
538
+ writeFileSync(path, contents)
539
+ }
540
+
541
+ /**
542
+ * Crea .ecomiq/node_modules con symlinks a las dependencias del framework
543
+ * y al propio framework. Así el código generado resuelve sus imports sin
544
+ * importar el package manager ni el layout (hoisted, pnpm, monorepo).
545
+ */
546
+ function linkFrameworkDependencies(appDir, frameworkRoot) {
547
+ const source = join(frameworkRoot, 'node_modules')
548
+ const target = join(appDir, 'node_modules')
549
+ mkdirSync(target, { recursive: true })
550
+
551
+ // el framework mismo, para imports '@ecomiq/storefront/...'
552
+ linkInto(join(target, '@ecomiq'), 'storefront', frameworkRoot)
553
+ // limpia el nombre legacy del paquete renombrado
554
+ const legacyFramework = join(target, '@ecomiq', 'framework')
555
+ try {
556
+ if (lstatSync(legacyFramework).isSymbolicLink()) unlinkSync(legacyFramework)
557
+ } catch {
558
+ /* no existía */
559
+ }
560
+
561
+ if (!existsSync(source)) return // instalación hoisted: se resuelve subiendo directorios
562
+ for (const entry of readdirSync(source)) {
563
+ if (entry === '.bin' || entry.startsWith('.')) continue
564
+ if (entry.startsWith('@')) {
565
+ for (const scoped of readdirSync(join(source, entry))) {
566
+ linkInto(join(target, entry), scoped, join(source, entry, scoped))
567
+ }
568
+ } else {
569
+ linkInto(target, entry, join(source, entry))
570
+ }
571
+ }
572
+ }
573
+
574
+ function linkInto(dir, name, targetPath) {
575
+ mkdirSync(dir, { recursive: true })
576
+ const linkPath = join(dir, name)
577
+ try {
578
+ const current = lstatSync(linkPath)
579
+ if (current.isSymbolicLink()) {
580
+ const resolved = resolve(dir, readlinkSync(linkPath))
581
+ if (resolved === resolve(targetPath)) return
582
+ unlinkSync(linkPath)
583
+ } else if (current.isDirectory() || current.isFile()) {
584
+ return
585
+ }
586
+ } catch {
587
+ /* no existe aún */
588
+ }
589
+ try {
590
+ symlinkSync(targetPath, linkPath, 'junction')
591
+ } catch {
592
+ /* FS sin symlinks: la resolución cae al hoisting */
593
+ }
594
+ }
595
+
596
+ function slugify(s) {
597
+ return s
598
+ .toLowerCase()
599
+ .normalize('NFD')
600
+ .replace(/[\u0300-\u036f]/g, '')
601
+ .replace(/[^a-z0-9]+/g, '-')
602
+ .replace(/^-|-$/g, '')
603
+ }