@jay-framework/jay-stack-cli 0.25.0 → 0.25.2

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.
@@ -5,18 +5,18 @@ import { JayRouteParamType, scanRoutes, parseRouteSegments } from "@jay-framewor
5
5
  import { getLogger } from "@jay-framework/logger";
6
6
  import path__default from "node:path";
7
7
  import fs from "node:fs/promises";
8
- import { createRequire } from "node:module";
9
- import { runLoadParams, scanPlugins, makePassthroughInstanceComponent, DevSlowlyChangingPhase, slowRenderInstances } from "@jay-framework/stack-server-runtime";
8
+ import crypto, { createHash } from "node:crypto";
9
+ import { jayRuntime } from "@jay-framework/vite-plugin";
10
10
  import { parseJayFile, JAY_IMPORT_RESOLVER, assignCoordinatesToJayHtml, discoverHeadlessInstances, generateElementHydrateFile, generateServerElementFile } from "@jay-framework/compiler-jay-html";
11
11
  import { normalizeActionEntry, checkValidationErrors, RuntimeMode } from "@jay-framework/compiler-shared";
12
- import crypto, { createHash } from "node:crypto";
12
+ import { parse } from "node-html-parser";
13
+ import { transform } from "esbuild";
14
+ import { createRequire } from "node:module";
15
+ import { runLoadParams, scanPlugins, makePassthroughInstanceComponent, DevSlowlyChangingPhase, slowRenderInstances } from "@jay-framework/stack-server-runtime";
13
16
  import * as productionServer from "@jay-framework/production-server";
14
- import { generateSitemap } from "@jay-framework/production-server";
17
+ import { generateSiteJsonLd, generateSitemap, generateRobotsTxtIfAbsent, generateLlmsTxt } from "@jay-framework/production-server";
15
18
  export * from "@jay-framework/production-server";
16
19
  import fs__default from "node:fs";
17
- import { jayRuntime } from "@jay-framework/vite-plugin";
18
- import { parse } from "node-html-parser";
19
- import { transform } from "esbuild";
20
20
  function isCompilableTypeScriptFile(fileName) {
21
21
  return fileName.endsWith(".ts") && !fileName.endsWith(".d.ts") && fileName !== "page.ts";
22
22
  }
@@ -40,7 +40,7 @@ async function collectTypeScriptEntries(rootDir, entryPrefix, pages) {
40
40
  }
41
41
  async function discoverServerEntries(projectRoot, pagesRoot) {
42
42
  const logger = getLogger();
43
- const routes = await scanRoutes(pagesRoot, {
43
+ const { routes, errorPages } = await scanRoutes(pagesRoot, {
44
44
  jayHtmlFilename: "page.jay-html",
45
45
  compFilename: "page.ts"
46
46
  });
@@ -52,6 +52,13 @@ async function discoverServerEntries(projectRoot, pagesRoot) {
52
52
  pages[entryName] = route.compPath;
53
53
  }
54
54
  }
55
+ for (const errorPage of errorPages) {
56
+ if (errorPage.compPath) {
57
+ const relativePath = path__default.relative(projectRoot, errorPage.compPath);
58
+ const entryName = relativePath.replace(/^src\//, "").replace(/\.ts$/, "");
59
+ pages[entryName] = errorPage.compPath;
60
+ }
61
+ }
55
62
  const actions = {};
56
63
  const actionsDir = path__default.join(projectRoot, "src", "actions");
57
64
  try {
@@ -96,9 +103,9 @@ async function discoverServerEntries(projectRoot, pagesRoot) {
96
103
  }
97
104
  const entries = { init, pages, actions };
98
105
  logger.info(
99
- `[Build] Discovered: ${Object.keys(pages).length} pages, ${Object.keys(actions).length} actions, init: ${init ? "yes" : "no"}`
106
+ `[Build] Discovered: ${Object.keys(pages).length} pages, ${Object.keys(actions).length} actions, ${errorPages.length} error pages, init: ${init ? "yes" : "no"}`
100
107
  );
101
- return { entries, routes };
108
+ return { entries, routes, errorPages };
102
109
  }
103
110
  async function buildServerCode(entries, jayOptions, outputDir, projectRoot) {
104
111
  const logger = getLogger();
@@ -145,127 +152,314 @@ async function buildServerCode(entries, jayOptions, outputDir, projectRoot) {
145
152
  });
146
153
  logger.info(`[Build] Server code compiled to ${outputDir}`);
147
154
  }
148
- createRequire(import.meta.url);
149
- const FRAMEWORK_PACKAGES = [
150
- "@jay-framework/stack-client-runtime",
151
- "@jay-framework/component",
152
- "@jay-framework/reactive",
153
- "@jay-framework/runtime",
154
- "@jay-framework/view-state-merge",
155
- "@jay-framework/fullstack-component"
156
- ];
157
- async function buildSharedChunks(outputDir, _projectRoot, minify = true, pluginClientPackages = []) {
158
- const logger = getLogger();
159
- logger.info("[Build] Building shared client chunks...");
155
+ async function compileServerElement(jayHtmlContent, jayHtmlFilename, jayHtmlDir, outputPath, projectRoot, tsConfigFilePath, sourceDir, minifyCss = true) {
156
+ const jayFile = await parseJayFile(
157
+ jayHtmlContent,
158
+ jayHtmlFilename,
159
+ jayHtmlDir,
160
+ { relativePath: tsConfigFilePath },
161
+ JAY_IMPORT_RESOLVER,
162
+ projectRoot,
163
+ sourceDir
164
+ );
165
+ const parsedJayFile = checkValidationErrors(jayFile);
166
+ const serverElementCode = checkValidationErrors(generateServerElementFile(parsedJayFile));
167
+ const outputDir = path__default.dirname(outputPath);
160
168
  await fs.mkdir(outputDir, { recursive: true });
161
- const allPackages = [...FRAMEWORK_PACKAGES, ...pluginClientPackages];
162
- const entries = {};
163
- for (const pkg of allPackages) {
164
- const varName = pkgToVarName(pkg);
165
- const entryPath = path__default.join(outputDir, `_shared_${varName}.js`);
166
- await fs.writeFile(entryPath, `export * from '${pkg}';
167
- `);
168
- entries[varName] = entryPath;
169
+ const tsPath = outputPath.replace(/\.js$/, ".ts");
170
+ await fs.writeFile(tsPath, serverElementCode, "utf-8");
171
+ const jayOptions = { tsConfigFilePath };
172
+ await build({
173
+ root: projectRoot,
174
+ publicDir: false,
175
+ plugins: [jayRuntime(jayOptions)],
176
+ build: {
177
+ outDir: outputDir,
178
+ emptyOutDir: false,
179
+ minify: false,
180
+ ssr: true,
181
+ rollupOptions: {
182
+ input: { [path__default.basename(outputPath, ".js")]: tsPath },
183
+ external: [/^node:/, /^@jay-framework\//],
184
+ output: {
185
+ entryFileNames: "[name].js",
186
+ format: "es"
187
+ }
188
+ }
189
+ },
190
+ logLevel: "warn"
191
+ });
192
+ await fs.rm(tsPath, { force: true });
193
+ let cssFile;
194
+ let cssImports;
195
+ const css = parsedJayFile.css;
196
+ if (css) {
197
+ cssImports = extractCssImportUrls(css);
198
+ const cssContent = minifyCss ? (await transform(css, { loader: "css", minify: true })).code : css;
199
+ const hash = createHash("sha256").update(cssContent).digest("hex").slice(0, 8);
200
+ const baseName = path__default.basename(outputPath, ".server-element.js");
201
+ const cssFilename = `${baseName}-${hash}.css`;
202
+ await fs.writeFile(path__default.join(outputDir, cssFilename), cssContent, "utf-8");
203
+ cssFile = cssFilename;
169
204
  }
170
- const dedupePackages = [...allPackages, "@jay-framework/list-compare"];
205
+ getLogger().info(`[Build] Compiled server element: ${path__default.basename(outputPath)}`);
206
+ return {
207
+ cssFile,
208
+ cssImports,
209
+ headMeta: parsedJayFile.headMeta,
210
+ scripts: parsedJayFile.scripts
211
+ };
212
+ }
213
+ async function compileRouteServerElement(jayHtmlPath, outputPath, projectRoot, tsConfigFilePath, minifyCss = true) {
214
+ const jayHtmlContent = await fs.readFile(jayHtmlPath, "utf-8");
215
+ const sourceDir = path__default.dirname(jayHtmlPath);
216
+ const outputDir = path__default.dirname(outputPath);
217
+ const jayHtml = resolveJayHtmlPaths(jayHtmlContent, sourceDir, outputDir);
218
+ return compileServerElement(
219
+ jayHtml,
220
+ path__default.basename(jayHtmlPath),
221
+ outputDir,
222
+ outputPath,
223
+ projectRoot,
224
+ tsConfigFilePath,
225
+ sourceDir,
226
+ minifyCss
227
+ );
228
+ }
229
+ async function compileRouteHydrateScript(jayHtmlPath, outputDir, projectRoot, tsConfigFilePath, minify = true) {
230
+ const jayHtmlContent = await fs.readFile(jayHtmlPath, "utf-8");
231
+ const sourceDir = path__default.dirname(jayHtmlPath);
232
+ const jayHtml = resolveJayHtmlPaths(jayHtmlContent, sourceDir, outputDir);
233
+ const jayFile = await parseJayFile(
234
+ jayHtml,
235
+ path__default.basename(jayHtmlPath),
236
+ outputDir,
237
+ { relativePath: tsConfigFilePath },
238
+ JAY_IMPORT_RESOLVER,
239
+ projectRoot,
240
+ sourceDir
241
+ );
242
+ const parsedJayFile = checkValidationErrors(jayFile);
243
+ const hydrateCode = checkValidationErrors(
244
+ generateElementHydrateFile(parsedJayFile, RuntimeMode.MainTrusted)
245
+ );
246
+ await fs.mkdir(outputDir, { recursive: true });
247
+ const tsPath = path__default.join(outputDir, "route.hydrate.ts");
248
+ await fs.writeFile(tsPath, hydrateCode, "utf-8");
249
+ const jayOptions = { tsConfigFilePath };
171
250
  await build({
251
+ root: projectRoot,
172
252
  publicDir: false,
253
+ plugins: [...jayStackCompiler(jayOptions)],
173
254
  build: {
174
255
  outDir: outputDir,
175
- emptyOutDir: true,
256
+ emptyOutDir: false,
176
257
  minify,
177
- manifest: "vite-manifest.json",
258
+ manifest: "route-hydrate-manifest.json",
178
259
  rollupOptions: {
179
- input: entries,
260
+ input: { "route.hydrate": tsPath },
261
+ external: (id) => id.startsWith("@jay-framework/"),
180
262
  output: {
181
263
  entryFileNames: "[name]-[hash].js",
182
- chunkFileNames: "[name]-[hash].js",
183
264
  format: "es"
184
265
  },
185
266
  preserveEntrySignatures: "exports-only"
186
267
  }
187
268
  },
188
- resolve: {
189
- dedupe: dedupePackages
190
- },
191
269
  logLevel: "warn"
192
270
  });
193
- for (const pkg of allPackages) {
194
- const varName = pkgToVarName(pkg);
195
- await fs.rm(path__default.join(outputDir, `_shared_${varName}.js`), { force: true });
196
- }
197
- const manifest = await parseViteManifest(outputDir, allPackages);
198
- logger.info(`[Build] Shared chunks built: ${Object.keys(manifest).length} entries`);
199
- return { manifest, outputDir };
200
- }
201
- function pkgToVarName(pkg) {
202
- return pkg.replace("@jay-framework/", "").replace(/[/-]/g, "_");
271
+ await fs.rm(tsPath, { force: true });
272
+ const manifestPath = path__default.join(outputDir, "route-hydrate-manifest.json");
273
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8"));
274
+ await fs.rm(manifestPath, { force: true });
275
+ const entryKey = Object.keys(manifest).find((k) => manifest[k].isEntry);
276
+ if (!entryKey) throw new Error("No entry in route hydrate manifest");
277
+ const jsFile = manifest[entryKey].file;
278
+ getLogger().info(`[Build] Compiled route hydrate script: ${jsFile}`);
279
+ return { jsFile };
203
280
  }
204
- async function parseViteManifest(outputDir, packages) {
205
- const viteManifestPath = path__default.join(outputDir, "vite-manifest.json");
206
- const raw = JSON.parse(await fs.readFile(viteManifestPath, "utf-8"));
207
- const varNameToPackage = /* @__PURE__ */ new Map();
208
- for (const pkg of packages) {
209
- varNameToPackage.set(pkgToVarName(pkg), pkg);
281
+ function resolveJayHtmlPaths(html, sourceDir, targetDir) {
282
+ const root = parse(html, {
283
+ comment: true,
284
+ blockTextElements: { script: true, style: true }
285
+ });
286
+ const rewrite = (el, attr) => {
287
+ const val = el.getAttribute(attr);
288
+ if (val && (val.startsWith("./") || val.startsWith("../"))) {
289
+ const abs = path__default.resolve(sourceDir, val);
290
+ let rel = path__default.relative(targetDir, abs);
291
+ if (!rel.startsWith(".")) rel = "./" + rel;
292
+ el.setAttribute(attr, rel);
293
+ }
294
+ };
295
+ for (const el of root.querySelectorAll('script[type="application/jay-data"]')) {
296
+ rewrite(el, "contract");
210
297
  }
211
- const manifest = {};
212
- for (const [, entry] of Object.entries(raw)) {
213
- if (!entry.isEntry) continue;
214
- const outputBase = path__default.basename(entry.file, ".js");
215
- for (const [varName, pkg] of varNameToPackage) {
216
- if (outputBase.startsWith(varName)) {
217
- manifest[pkg] = entry.file;
218
- break;
219
- }
298
+ for (const el of root.querySelectorAll('script[type="application/jay-headless"]')) {
299
+ rewrite(el, "src");
300
+ rewrite(el, "contract");
301
+ }
302
+ for (const el of root.querySelectorAll('script[type="application/jay-headfull"]')) {
303
+ rewrite(el, "src");
304
+ rewrite(el, "contract");
305
+ }
306
+ for (const el of root.querySelectorAll('link[rel="stylesheet"]')) {
307
+ rewrite(el, "href");
308
+ }
309
+ return root.toString();
310
+ }
311
+ function extractCssImportUrls(css) {
312
+ const imports = [];
313
+ const re = /@import\s*(?:url\(\s*['"]?([^'")]+)['"]?\s*\)|['"]([^'"]+)['"])/g;
314
+ let match;
315
+ while ((match = re.exec(css)) !== null) {
316
+ const url = match[1] || match[2];
317
+ if (url.startsWith("https://")) {
318
+ imports.push(url);
220
319
  }
221
320
  }
222
- const sharedManifestPath = path__default.join(outputDir, "shared-manifest.json");
223
- await fs.writeFile(sharedManifestPath, JSON.stringify(manifest, null, 2));
224
- await fs.rm(viteManifestPath, { force: true });
225
- return manifest;
321
+ return imports;
226
322
  }
227
- const require$1 = createRequire(import.meta.url);
228
- async function loadProductionPageParts(route, pageModule, jayHtmlContent, projectRoot, tsConfigFilePath, serverBuildDir) {
229
- const exportName = route.componentExport || "page";
230
- const compDefinition = pageModule[exportName] ?? pageModule.default;
231
- const parts = compDefinition ? [{ compDefinition, clientImport: "", clientPart: "" }] : [];
232
- const dirName = path__default.dirname(route.jayHtmlPath);
233
- const fileName = path__default.basename(route.jayHtmlPath);
234
- const jayHtmlWithValidations = await parseJayFile(
235
- jayHtmlContent,
236
- fileName,
237
- dirName,
238
- { relativePath: tsConfigFilePath },
239
- JAY_IMPORT_RESOLVER,
240
- projectRoot
241
- );
242
- const jayHtml = checkValidationErrors(jayHtmlWithValidations);
243
- const headlessInstanceComponents = [];
244
- const keyedPartModules = [];
245
- const headlessModuleInfos = [];
246
- const headlessImports = jayHtml.headlessImports ?? [];
247
- getLogger().info(
248
- `[Build] headlessImports for ${fileName}: ${headlessImports.length}, keys: ${Object.keys(jayHtml).join(",")}`
249
- );
250
- for (const headlessImport of headlessImports) {
251
- const module = headlessImport.codeLink.module;
252
- const name = headlessImport.codeLink.names[0].name;
253
- const isLocalModule = module[0] === "." || module[0] === "/";
254
- let modulePath;
255
- if (isLocalModule) {
256
- const sourcePath = path__default.resolve(dirName, module);
257
- if (serverBuildDir) {
258
- const relativeToSrc = path__default.relative(path__default.join(projectRoot, "src"), sourcePath);
259
- let compiledPath = path__default.join(serverBuildDir, relativeToSrc);
260
- compiledPath = compiledPath.replace(/\.ts$/, ".js");
261
- if (!compiledPath.endsWith(".js")) {
262
- const indexPath = path__default.join(compiledPath, "index.js");
263
- try {
264
- await fs.access(indexPath);
265
- compiledPath = indexPath;
266
- } catch {
267
- compiledPath += ".js";
268
- }
323
+ async function generateRouteHydrationEntry(options) {
324
+ const {
325
+ hydrateImport,
326
+ pageModulePath,
327
+ pageExportName = "page",
328
+ trackByMap,
329
+ outputPath,
330
+ keyedParts = [],
331
+ clientInits = []
332
+ } = options;
333
+ const partImports = keyedParts.map((p, i) => `import { ${p.exportName} as keyedPart${i} } from '${p.modulePath}';`).join("\n");
334
+ const hasPageModule = pageModulePath && pageExportName;
335
+ const pagePartExpr = hasPageModule ? `pagePart && pagePart.comp ? { comp: pagePart.comp, contextMarkers: pagePart.contexts || [] } : null` : `null`;
336
+ const partsArray = [
337
+ pagePartExpr,
338
+ ...keyedParts.map(
339
+ (p, i) => `keyedPart${i} && keyedPart${i}.comp ? { comp: keyedPart${i}.comp, contextMarkers: keyedPart${i}.contexts || [], key: '${p.key}' } : null`
340
+ )
341
+ ];
342
+ const pageImport = hasPageModule ? `import { ${pageExportName} as pagePart } from '${pageModulePath}';` : "";
343
+ const initImports = clientInits.map((ci, i) => `import { ${ci.exportName} as clientInit${i} } from '${ci.modulePath}';`).join("\n");
344
+ const initCalls = clientInits.map(
345
+ (ci, i) => ` if (clientInit${i}?._clientInit) await clientInit${i}._clientInit(clientInitData['${ci.key}'] || {});`
346
+ ).join("\n");
347
+ const code = `import { hydrateCompositeJayComponent } from '@jay-framework/stack-client-runtime';
348
+ import { deepMergeViewStates } from '@jay-framework/view-state-merge';
349
+ import { hydrate } from '${hydrateImport}';
350
+ ${pageImport}
351
+ ${partImports}
352
+ ${initImports}
353
+
354
+ const trackByMap = ${JSON.stringify(trackByMap)};
355
+
356
+ export async function init(slowViewState, fastViewState, fastCarryForward, clientInitData) {
357
+ ${initCalls}
358
+ const viewState = deepMergeViewStates(slowViewState, fastViewState, trackByMap);
359
+ const target = document.getElementById('target');
360
+ const rootElement = target.firstElementChild;
361
+ const parts = [
362
+ ${partsArray.join(",\n ")}
363
+ ].filter(p => p !== null);
364
+ const pageComp = hydrateCompositeJayComponent(
365
+ hydrate, viewState, fastCarryForward,
366
+ parts, trackByMap, rootElement
367
+ );
368
+ return pageComp({});
369
+ }
370
+ `;
371
+ const outputDir = path__default.dirname(outputPath);
372
+ await fs.mkdir(outputDir, { recursive: true });
373
+ await fs.writeFile(outputPath, code, "utf-8");
374
+ getLogger().info(`[Build] Generated route hydration entry: ${path__default.basename(outputPath)}`);
375
+ }
376
+ async function buildInstanceClient(hydrateEntryPath, instanceId, outputDir, projectRoot, jayOptions, minify = true, pagesRoot, buildDir) {
377
+ const logger = getLogger();
378
+ await fs.mkdir(outputDir, { recursive: true });
379
+ const fullJayOptions = {
380
+ ...jayOptions,
381
+ ...pagesRoot && buildDir ? { pagesRoot, buildFolder: buildDir } : {}
382
+ };
383
+ await build({
384
+ root: projectRoot,
385
+ publicDir: false,
386
+ plugins: [...jayStackCompiler(fullJayOptions)],
387
+ build: {
388
+ outDir: outputDir,
389
+ emptyOutDir: false,
390
+ minify,
391
+ manifest: `${instanceId}-manifest.json`,
392
+ rollupOptions: {
393
+ input: { [instanceId]: hydrateEntryPath },
394
+ external: (id) => id.startsWith("@jay-framework/") || id === "jay-route-hydrate",
395
+ output: {
396
+ entryFileNames: "[name]-[hash].js",
397
+ chunkFileNames: "chunks/[name]-[hash].js",
398
+ assetFileNames: "[name]-[hash].[ext]",
399
+ format: "es"
400
+ },
401
+ preserveEntrySignatures: "exports-only"
402
+ }
403
+ },
404
+ logLevel: "warn"
405
+ });
406
+ const manifestPath = path__default.join(outputDir, `${instanceId}-manifest.json`);
407
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8"));
408
+ await fs.rm(manifestPath, { force: true });
409
+ const entryKey = Object.keys(manifest).find((k) => manifest[k].isEntry);
410
+ if (!entryKey) {
411
+ throw new Error(`No entry found in instance build manifest for ${instanceId}`);
412
+ }
413
+ const entry = manifest[entryKey];
414
+ const result = {
415
+ jsFile: entry.file,
416
+ cssFile: entry.css?.[0]
417
+ };
418
+ logger.info(`[Build] Client bundle: ${result.jsFile}`);
419
+ return result;
420
+ }
421
+ const require$1 = createRequire(import.meta.url);
422
+ async function loadProductionPageParts(route, pageModule, jayHtmlContent, projectRoot, tsConfigFilePath, serverBuildDir) {
423
+ const exportName = route.componentExport || "page";
424
+ const compDefinition = pageModule[exportName] ?? pageModule.default;
425
+ const parts = compDefinition ? [{ compDefinition, clientImport: "", clientPart: "" }] : [];
426
+ const dirName = path__default.dirname(route.jayHtmlPath);
427
+ const fileName = path__default.basename(route.jayHtmlPath);
428
+ const jayHtmlWithValidations = await parseJayFile(
429
+ jayHtmlContent,
430
+ fileName,
431
+ dirName,
432
+ { relativePath: tsConfigFilePath },
433
+ JAY_IMPORT_RESOLVER,
434
+ projectRoot
435
+ );
436
+ const jayHtml = checkValidationErrors(jayHtmlWithValidations);
437
+ const headlessInstanceComponents = [];
438
+ const keyedPartModules = [];
439
+ const headlessModuleInfos = [];
440
+ const headlessImports = jayHtml.headlessImports ?? [];
441
+ getLogger().info(
442
+ `[Build] headlessImports for ${fileName}: ${headlessImports.length}, keys: ${Object.keys(jayHtml).join(",")}`
443
+ );
444
+ for (const headlessImport of headlessImports) {
445
+ const module = headlessImport.codeLink.module;
446
+ const name = headlessImport.codeLink.names[0].name;
447
+ const isLocalModule = module[0] === "." || module[0] === "/";
448
+ let modulePath;
449
+ if (isLocalModule) {
450
+ const sourcePath = path__default.resolve(dirName, module);
451
+ if (serverBuildDir) {
452
+ const relativeToSrc = path__default.relative(path__default.join(projectRoot, "src"), sourcePath);
453
+ let compiledPath = path__default.join(serverBuildDir, relativeToSrc);
454
+ compiledPath = compiledPath.replace(/\.ts$/, ".js");
455
+ if (!compiledPath.endsWith(".js")) {
456
+ const indexPath = path__default.join(compiledPath, "index.js");
457
+ try {
458
+ await fs.access(indexPath);
459
+ compiledPath = indexPath;
460
+ } catch {
461
+ compiledPath += ".js";
462
+ }
269
463
  }
270
464
  modulePath = compiledPath;
271
465
  } else {
@@ -411,664 +605,622 @@ function buildPagePartsConfig(pageParts, pageServerModule, pageExportName, build
411
605
  }))
412
606
  };
413
607
  }
414
- function hashParams(params, suffix) {
415
- const sorted = Object.keys(params).sort().reduce(
416
- (acc, key) => {
417
- acc[key] = params[key];
418
- return acc;
419
- },
420
- {}
421
- );
422
- const json = JSON.stringify(sorted);
423
- if (json === "{}" && !suffix) return "";
424
- const input = suffix ? json + ":" + suffix : json;
425
- return "_" + crypto.createHash("md5").update(input).digest("hex").substring(0, 8);
426
- }
427
- async function buildInstance(route, params, pageModule, ctx, routeServerElementPath, routeCssPath, routeHydratePath, routeClientBundlePath) {
608
+ async function buildErrorPages(errorPages, ctx, projectRoot, pagesRoot, tsConfigFilePath, minify, sharedManifest, buildDir) {
428
609
  const logger = getLogger();
429
- const routeDir = route.rawRoute.replace(/^\//, "") || "index";
430
- const paramHash = hashParams(params, ctx.rebuildSuffix);
431
- const instanceId = `page${paramHash}`;
432
- const backendInstanceDir = path__default.join(ctx.backendDir, "pre-rendered", routeDir);
433
- const frontendInstanceDir = path__default.join(ctx.frontendDir, "pages", routeDir);
434
- await fs.mkdir(backendInstanceDir, { recursive: true });
435
- await fs.mkdir(frontendInstanceDir, { recursive: true });
436
- const jayHtmlContent = await fs.readFile(route.jayHtmlPath, "utf-8");
437
- path__default.dirname(route.jayHtmlPath);
438
- const serverBuildDir = path__default.join(ctx.backendDir, "server");
439
- const pageParts = await loadProductionPageParts(
440
- route,
441
- pageModule,
442
- jayHtmlContent,
443
- ctx.projectRoot,
444
- ctx.tsConfigFilePath,
445
- serverBuildDir
446
- );
447
- const contracts = [
448
- .../* @__PURE__ */ new Set([
449
- ...pageParts.headlessInstanceComponents.map((c) => c.contractName),
450
- ...pageParts.parts.filter((p) => p.contractInfo?.contractName).map((p) => p.contractInfo.contractName)
451
- ])
452
- ];
453
- const pagePartsConfigPath = path__default.join(backendInstanceDir, "page-parts.json");
454
- const exportName = route.componentExport || "page";
455
- let pageServerModule = "";
456
- let pageIsPlugin = false;
457
- if (route.compPath) {
458
- if (route.componentExport) {
459
- pageServerModule = route.packageName || route.compPath;
460
- pageIsPlugin = true;
461
- } else {
462
- const relativePath = path__default.relative(ctx.projectRoot, route.compPath);
463
- pageServerModule = relativePath.replace(/^src\//, "server/").replace(/\.ts$/, ".js").replace(/\[/g, "_").replace(/\]/g, "_");
610
+ const result = {};
611
+ for (const errorPage of errorPages) {
612
+ if (!errorPage.jayHtmlPath) {
613
+ logger.warn(`[Build] Error page ${errorPage.token} has no page.jay-html — skipped`);
614
+ continue;
464
615
  }
465
- }
466
- const config = buildPagePartsConfig(
467
- pageParts,
468
- pageServerModule,
469
- exportName,
470
- ctx.backendDir,
471
- pageIsPlugin
472
- );
473
- await fs.writeFile(pagePartsConfigPath, JSON.stringify(config, null, 2));
474
- logger.info(`[Build] Page parts config: ${routeDir}/page-parts.json`);
475
- const slowPhase = new DevSlowlyChangingPhase();
476
- const slowResult = await slowPhase.runSlowlyForPage(
477
- params,
478
- { params },
479
- pageParts.parts,
480
- pageParts.discoveredInstances,
481
- pageParts.headlessInstanceComponents,
482
- route.jayHtmlPath
483
- );
484
- if (slowResult.kind !== "PhaseOutput") {
485
- if (slowResult.kind === "ClientError" || slowResult.kind === "Redirect") {
486
- return {
487
- status: "skipped",
488
- reason: `${slowResult.kind} ${slowResult.status ?? ""} ${slowResult.message ?? ""}`.trim()
489
- };
616
+ const outputDirName = path__default.join("_errors", errorPage.token);
617
+ const backendPageDir = path__default.join(ctx.backendDir, "pre-rendered", outputDirName);
618
+ const frontendPageDir = path__default.join(ctx.frontendDir, "pages", outputDirName);
619
+ await fs.mkdir(backendPageDir, { recursive: true });
620
+ await fs.mkdir(frontendPageDir, { recursive: true });
621
+ const entry = {
622
+ token: errorPage.token,
623
+ serverElementPath: ""
624
+ };
625
+ const serverElementPath = path__default.join(backendPageDir, "route.server-element.js");
626
+ try {
627
+ const seResult = await compileRouteServerElement(
628
+ errorPage.jayHtmlPath,
629
+ serverElementPath,
630
+ projectRoot,
631
+ tsConfigFilePath,
632
+ minify
633
+ );
634
+ entry.serverElementPath = path__default.relative(ctx.backendDir, serverElementPath);
635
+ if (seResult.cssFile) {
636
+ const srcCss = path__default.join(backendPageDir, seResult.cssFile);
637
+ const dstCss = path__default.join(frontendPageDir, seResult.cssFile);
638
+ try {
639
+ await fs.rename(srcCss, dstCss);
640
+ } catch {
641
+ await fs.copyFile(srcCss, dstCss);
642
+ await fs.rm(srcCss, { force: true });
643
+ }
644
+ entry.routeCssPath = path__default.relative(ctx.frontendDir, dstCss);
645
+ }
646
+ if (seResult.cssImports?.length) entry.cssImports = seResult.cssImports;
647
+ if (seResult.headMeta) entry.headMeta = seResult.headMeta;
648
+ if (seResult.scripts?.length) entry.scripts = seResult.scripts;
649
+ logger.important(`[Build] Error page server element: ${errorPage.token}`);
650
+ } catch (err) {
651
+ logger.error(
652
+ `[Build] Error page server element FAILED ${errorPage.token}: ${err.message}`
653
+ );
654
+ continue;
490
655
  }
491
- throw new Error(
492
- `Slow render failed for ${route.rawRoute} with params ${JSON.stringify(params)}: ${slowResult.kind}`
493
- );
494
- }
495
- const slowViewState = slowResult.rendered;
496
- const carryForward = slowResult.carryForward;
497
- if (pageParts.discoveredInstances.length > 0 && pageParts.headlessInstanceComponents.length > 0) {
498
- const slowResult2 = await slowRenderInstances(
499
- pageParts.discoveredInstances,
500
- pageParts.headlessInstanceComponents,
501
- {
502
- pageViewState: slowViewState,
503
- pageParams: params,
504
- pageProps: { language: "en", url: "" }
656
+ try {
657
+ const hydrateResult = await compileRouteHydrateScript(
658
+ errorPage.jayHtmlPath,
659
+ frontendPageDir,
660
+ projectRoot,
661
+ tsConfigFilePath,
662
+ minify
663
+ );
664
+ entry.routeHydratePath = path__default.relative(
665
+ ctx.frontendDir,
666
+ path__default.join(frontendPageDir, hydrateResult.jsFile)
667
+ );
668
+ try {
669
+ const hydrateCode = await fs.readFile(
670
+ path__default.join(frontendPageDir, hydrateResult.jsFile),
671
+ "utf-8"
672
+ );
673
+ const importMatches = hydrateCode.match(/from\s*"([^"]+)"/g) || [];
674
+ const deps = importMatches.map((m) => m.replace(/^from\s*"/, "").replace(/"$/, "")).filter((dep) => sharedManifest[dep]);
675
+ if (deps.length > 0) entry.sharedDeps = deps;
676
+ } catch {
505
677
  }
506
- );
507
- if (slowResult2) {
508
- const existingInstances = carryForward.__instances || {
509
- discovered: [],
510
- carryForwards: {}
511
- };
512
- carryForward.__instances = {
513
- discovered: [
514
- ...existingInstances.discovered,
515
- ...slowResult2.instancePhaseData.discovered
516
- ],
517
- carryForwards: {
518
- ...existingInstances.carryForwards,
519
- ...slowResult2.instancePhaseData.carryForwards
520
- },
521
- slowViewStates: {
522
- ...existingInstances.slowViewStates || {},
523
- ...slowResult2.instancePhaseData.slowViewStates
524
- }
525
- };
678
+ logger.important(`[Build] Error page hydrate script: ${errorPage.token}`);
679
+ } catch (err) {
680
+ logger.error(
681
+ `[Build] Error page hydrate script FAILED ${errorPage.token}: ${err.message}`
682
+ );
683
+ result[errorPage.token] = entry;
684
+ continue;
526
685
  }
686
+ try {
687
+ const ROUTE_HYDRATE_KEY = "jay-route-hydrate";
688
+ let pageModulePath = "";
689
+ if (errorPage.compPath) {
690
+ pageModulePath = "./" + path__default.relative(frontendPageDir, errorPage.compPath);
691
+ }
692
+ const pageParts = await loadProductionPageParts(
693
+ { jayHtmlPath: errorPage.jayHtmlPath },
694
+ {},
695
+ await fs.readFile(errorPage.jayHtmlPath, "utf-8"),
696
+ projectRoot,
697
+ tsConfigFilePath,
698
+ path__default.join(ctx.backendDir, "server")
699
+ );
700
+ entry.trackByMap = pageParts.serverTrackByMap || pageParts.clientTrackByMap;
701
+ const entryPath = path__default.join(frontendPageDir, "route.entry.ts");
702
+ await generateRouteHydrationEntry({
703
+ hydrateImport: ROUTE_HYDRATE_KEY,
704
+ pageModulePath,
705
+ pageExportName: "page",
706
+ trackByMap: pageParts.clientTrackByMap || {},
707
+ outputPath: entryPath,
708
+ keyedParts: pageParts.keyedPartModules,
709
+ clientInits: ctx.clientInits
710
+ });
711
+ const clientResult = await buildInstanceClient(
712
+ entryPath,
713
+ "route.client",
714
+ frontendPageDir,
715
+ projectRoot,
716
+ { tsConfigFilePath },
717
+ minify,
718
+ pagesRoot,
719
+ buildDir
720
+ );
721
+ await fs.rm(entryPath, { force: true });
722
+ entry.routeClientBundlePath = path__default.relative(
723
+ ctx.frontendDir,
724
+ path__default.join(frontendPageDir, clientResult.jsFile)
725
+ );
726
+ try {
727
+ const clientCode = await fs.readFile(
728
+ path__default.join(frontendPageDir, clientResult.jsFile),
729
+ "utf-8"
730
+ );
731
+ const clientImports = (clientCode.match(/from\s*"([^"]+)"/g) || []).map((m) => m.replace(/^from\s*"/, "").replace(/"$/, "")).filter((dep) => sharedManifest[dep]);
732
+ const existing = new Set(entry.sharedDeps || []);
733
+ for (const dep of clientImports) existing.add(dep);
734
+ if (existing.size > 0) entry.sharedDeps = [...existing];
735
+ } catch {
736
+ }
737
+ logger.important(`[Build] Error page client bundle: ${errorPage.token}`);
738
+ } catch (err) {
739
+ logger.error(
740
+ `[Build] Error page client bundle FAILED ${errorPage.token}: ${err.message}`
741
+ );
742
+ }
743
+ result[errorPage.token] = entry;
527
744
  }
528
- if (pageParts.forEachInstances.length > 0) {
529
- const existingInstances = carryForward.__instances || {
530
- discovered: [],
531
- carryForwards: {}
532
- };
533
- existingInstances.forEachInstances = pageParts.forEachInstances;
534
- carryForward.__instances = existingInstances;
535
- }
536
- const cachePath = path__default.join(backendInstanceDir, `${instanceId}.cache.json`);
537
- await fs.writeFile(
538
- cachePath,
539
- JSON.stringify({
540
- slowViewState,
541
- carryForward
542
- }),
543
- "utf-8"
544
- );
545
- logger.info(`[Build] Instance data: ${routeDir}/${instanceId}`);
546
- const serverElementPath = routeServerElementPath ? path__default.join(ctx.backendDir, routeServerElementPath) : path__default.join(backendInstanceDir, `${instanceId}.server-element.js`);
547
- const instanceEntry = {
548
- params,
549
- cachePath: path__default.relative(ctx.backendDir, cachePath),
550
- serverElementPath: path__default.relative(ctx.backendDir, serverElementPath),
551
- clientBundlePath: routeClientBundlePath || "",
552
- clientCssPath: routeCssPath
553
- };
554
- return { status: "success", instanceEntry, slowViewState, carryForward, contracts };
745
+ return result;
555
746
  }
556
- function convertSegments(segments) {
557
- return segments.map((s) => {
558
- if (typeof s === "string") {
559
- return { type: "static", value: s };
560
- }
561
- switch (s.type) {
562
- case JayRouteParamType.single:
563
- return { type: "param", value: s.name };
564
- case JayRouteParamType.catchAll:
565
- return { type: "catchAll", value: s.name };
566
- case JayRouteParamType.optional:
567
- return { type: "optional", value: s.name };
568
- }
747
+ createRequire(import.meta.url);
748
+ const FRAMEWORK_PACKAGES = [
749
+ "@jay-framework/stack-client-runtime",
750
+ "@jay-framework/component",
751
+ "@jay-framework/reactive",
752
+ "@jay-framework/runtime",
753
+ "@jay-framework/view-state-merge",
754
+ "@jay-framework/fullstack-component"
755
+ ];
756
+ async function buildSharedChunks(outputDir, _projectRoot, minify = true, pluginClientPackages = []) {
757
+ const logger = getLogger();
758
+ logger.info("[Build] Building shared client chunks...");
759
+ await fs.mkdir(outputDir, { recursive: true });
760
+ const allPackages = [...FRAMEWORK_PACKAGES, ...pluginClientPackages];
761
+ const entries = {};
762
+ for (const pkg of allPackages) {
763
+ const varName = pkgToVarName(pkg);
764
+ const entryPath = path__default.join(outputDir, `_shared_${varName}.js`);
765
+ await fs.writeFile(entryPath, `export * from '${pkg}';
766
+ `);
767
+ entries[varName] = entryPath;
768
+ }
769
+ const dedupePackages = [...allPackages, "@jay-framework/list-compare"];
770
+ await build({
771
+ publicDir: false,
772
+ build: {
773
+ outDir: outputDir,
774
+ emptyOutDir: true,
775
+ minify,
776
+ manifest: "vite-manifest.json",
777
+ rollupOptions: {
778
+ input: entries,
779
+ output: {
780
+ entryFileNames: "[name]-[hash].js",
781
+ chunkFileNames: "[name]-[hash].js",
782
+ format: "es"
783
+ },
784
+ preserveEntrySignatures: "exports-only"
785
+ }
786
+ },
787
+ resolve: {
788
+ dedupe: dedupePackages
789
+ },
790
+ logLevel: "warn"
569
791
  });
792
+ for (const pkg of allPackages) {
793
+ const varName = pkgToVarName(pkg);
794
+ await fs.rm(path__default.join(outputDir, `_shared_${varName}.js`), { force: true });
795
+ }
796
+ const manifest = await parseViteManifest(outputDir, allPackages);
797
+ logger.info(`[Build] Shared chunks built: ${Object.keys(manifest).length} entries`);
798
+ return { manifest, outputDir };
570
799
  }
571
- function buildRouteEntry(route, serverModulePath) {
572
- return {
573
- pattern: route.rawRoute,
574
- segments: convertSegments(route.segments),
575
- serverModule: serverModulePath,
576
- componentExport: route.componentExport,
577
- instances: [],
578
- ...route.devOnly && { devOnly: true }
579
- };
800
+ function pkgToVarName(pkg) {
801
+ return pkg.replace("@jay-framework/", "").replace(/[/-]/g, "_");
580
802
  }
581
- async function discoverActions(actionPaths, serverOutputDir, buildDir, projectRoot) {
582
- const actions = [];
583
- const plugins = [];
584
- for (const [entryName, sourcePath] of Object.entries(actionPaths)) {
585
- try {
586
- const code = await fs.readFile(sourcePath, "utf-8");
587
- const extracted = extractActionsFromSource(code, sourcePath);
588
- if (extracted.length > 0) {
589
- actions.push({
590
- serverModule: path__default.relative(
591
- buildDir,
592
- path__default.join(serverOutputDir, entryName + ".js")
593
- ),
594
- isPlugin: false,
595
- actionNames: extracted.map((a) => a.actionName)
596
- });
803
+ async function parseViteManifest(outputDir, packages) {
804
+ const viteManifestPath = path__default.join(outputDir, "vite-manifest.json");
805
+ const raw = JSON.parse(await fs.readFile(viteManifestPath, "utf-8"));
806
+ const varNameToPackage = /* @__PURE__ */ new Map();
807
+ for (const pkg of packages) {
808
+ varNameToPackage.set(pkgToVarName(pkg), pkg);
809
+ }
810
+ const manifest = {};
811
+ for (const [, entry] of Object.entries(raw)) {
812
+ if (!entry.isEntry) continue;
813
+ const outputBase = path__default.basename(entry.file, ".js");
814
+ for (const [varName, pkg] of varNameToPackage) {
815
+ if (outputBase.startsWith(varName)) {
816
+ manifest[pkg] = entry.file;
817
+ break;
597
818
  }
598
- } catch {
599
- getLogger().warn(`[Build] Could not extract actions from ${sourcePath}`);
600
819
  }
601
820
  }
602
- try {
603
- const scannedPlugins = await scanPlugins({ projectRoot });
604
- for (const [packageName, plugin] of scannedPlugins) {
605
- if (plugin.isLocal) continue;
606
- plugins.push({ name: plugin.manifest.name, packageName });
607
- const pluginActions = plugin.manifest.actions;
608
- if (pluginActions && pluginActions.length > 0) {
609
- const productionActions = pluginActions.map(normalizeActionEntry).filter((a) => !a.devOnly);
610
- const devOnlyCount = pluginActions.length - productionActions.length;
611
- if (productionActions.length > 0) {
612
- actions.push({
613
- serverModule: "",
614
- packageName,
615
- isPlugin: true,
616
- actionNames: productionActions.map((a) => a.name)
617
- });
821
+ const sharedManifestPath = path__default.join(outputDir, "shared-manifest.json");
822
+ await fs.writeFile(sharedManifestPath, JSON.stringify(manifest, null, 2));
823
+ await fs.rm(viteManifestPath, { force: true });
824
+ return manifest;
825
+ }
826
+ function crossProductParams(parts) {
827
+ if (parts.length === 0) return [];
828
+ if (parts.length === 1) return parts[0].values;
829
+ const logger = getLogger();
830
+ for (let i = 0; i < parts.length; i++) {
831
+ for (let j = i + 1; j < parts.length; j++) {
832
+ for (const key of parts[i].keys) {
833
+ if (parts[j].keys.has(key)) {
834
+ logger.warn(
835
+ `[Build] Multiple loadParams provide key "${key}" — using first provider`
836
+ );
618
837
  }
619
- getLogger().info(
620
- `[Build] Plugin actions from ${packageName}: ${productionActions.length}` + (devOnlyCount > 0 ? ` (excluded ${devOnlyCount} devOnly)` : "")
621
- );
622
838
  }
623
839
  }
624
- } catch (err) {
625
- getLogger().warn(`[Build] Plugin action scan failed: ${err.message}`);
626
840
  }
627
- return { actions, plugins };
841
+ let result = parts[0].values;
842
+ for (let i = 1; i < parts.length; i++) {
843
+ const next = parts[i].values;
844
+ const combined = [];
845
+ for (const a of result) {
846
+ for (const b of next) {
847
+ combined.push({ ...a, ...b });
848
+ }
849
+ }
850
+ result = combined;
851
+ }
852
+ return result;
628
853
  }
629
- async function writeRouteManifest(manifest, buildDir) {
630
- const manifestPath = path__default.join(buildDir, "route-manifest.json");
631
- await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
632
- getLogger().info(
633
- `[Build] Route manifest written: ${manifest.routes.length} routes, ${manifest.routes.reduce((n, r) => n + r.instances.length, 0)} instances`
634
- );
854
+ function paramsMatchInferred(params, inferredParams, optionalSegments) {
855
+ return Object.entries(inferredParams).every(([k, v]) => {
856
+ if (optionalSegments?.has(k)) return true;
857
+ return params[k] === v;
858
+ });
635
859
  }
636
- createRequire(import.meta.url);
637
- async function scanPluginRoutes(projectRoot, projectRoutes) {
638
- const logger = getLogger();
639
- const plugins = await scanPlugins({ projectRoot });
640
- const projectPaths = new Set(projectRoutes.map((r) => r.rawRoute));
641
- const pluginRoutes = [];
642
- for (const [, plugin] of plugins) {
643
- if (plugin.isLocal) continue;
644
- if (!plugin.manifest.routes) continue;
645
- for (const route of plugin.manifest.routes) {
646
- if (projectPaths.has(route.path)) {
647
- logger.info(
648
- `[Routes] Plugin "${plugin.manifest.name}" route ${route.path} skipped — project route takes precedence`
649
- );
860
+ function computeSpecificity(route) {
861
+ const dynamicCount = (route.rawRoute.match(/\[/g) || []).length;
862
+ const inferredCount = route.inferredParams ? Object.keys(route.inferredParams).length : 0;
863
+ const unresolvedCount = Math.max(0, dynamicCount - inferredCount);
864
+ return 0 - unresolvedCount;
865
+ }
866
+ function buildUrl(route, params) {
867
+ return route.rawRoute.replace(/\[\[(\w+)\]\]/g, (_, name) => {
868
+ const value = params[name];
869
+ if (!value) return "";
870
+ if (route.inferredParams?.[name] === value) return "";
871
+ return value;
872
+ }).replace(/\[(\w+)\]/g, (_, name) => params[name] || "").replace(/\/\/+/g, "/").replace(/\/$/, "") || "/";
873
+ }
874
+ function materializeRouteParams(routes, loadParamsResults) {
875
+ const entries = [];
876
+ for (const route of routes) {
877
+ const specificity = computeSpecificity(route);
878
+ if (!route.hasDynamicParams) {
879
+ const params = route.inferredParams || {};
880
+ entries.push({ route, params, url: route.rawRoute, specificity });
881
+ continue;
882
+ }
883
+ const allParams = loadParamsResults.get(route) || [];
884
+ for (const params of allParams) {
885
+ if (route.inferredParams && !paramsMatchInferred(params, route.inferredParams, route.optionalSegments)) {
650
886
  continue;
651
887
  }
652
- const jayHtmlPath = resolvePluginExport(plugin.pluginPath, route.jayHtml);
653
- if (!jayHtmlPath) {
654
- logger.warn(
655
- `[Routes] Plugin "${plugin.manifest.name}" route ${route.path}: jayHtml "${route.jayHtml}" not found`
656
- );
657
- continue;
888
+ let mergedParams = params;
889
+ if (route.inferredParams) {
890
+ mergedParams = { ...params };
891
+ for (const [k, v] of Object.entries(route.inferredParams)) {
892
+ if (!(k in mergedParams)) {
893
+ mergedParams[k] = v;
894
+ }
895
+ }
658
896
  }
659
- const compPath = resolvePluginModule(plugin.pluginPath);
660
- const componentExport = route.component;
661
- pluginRoutes.push({
662
- segments: parseRouteSegments(route.path),
663
- rawRoute: route.path,
664
- jayHtmlPath,
665
- compPath,
666
- componentExport,
667
- packageName: plugin.packageName,
668
- ...route.devOnly === true && { devOnly: true }
669
- });
670
- logger.info(
671
- `[Routes] Plugin "${plugin.manifest.name}" provides route ${route.path}${route.devOnly ? " (dev-only)" : ""}`
672
- );
897
+ const url = buildUrl(route, mergedParams);
898
+ entries.push({ route, params: mergedParams, url, specificity });
673
899
  }
674
900
  }
675
- return pluginRoutes;
901
+ return entries;
676
902
  }
677
- function resolvePluginExport(pluginPath, exportSubpath) {
678
- const normalized = exportSubpath.replace(/^\.\//, "");
679
- const packageJsonPath = path__default.join(pluginPath, "package.json");
680
- try {
681
- const packageJson = JSON.parse(fs__default.readFileSync(packageJsonPath, "utf-8"));
682
- if (packageJson.exports) {
683
- const exportKey = "./" + normalized;
684
- const exportValue = packageJson.exports[exportKey];
685
- if (exportValue) {
686
- const resolved = typeof exportValue === "string" ? exportValue : exportValue.default || exportValue.import || exportValue.require;
687
- if (resolved) return path__default.join(pluginPath, resolved);
688
- }
689
- }
690
- } catch {
691
- }
692
- for (const dir of ["dist", "lib", ""]) {
693
- const candidate = path__default.join(pluginPath, dir, normalized);
694
- try {
695
- fs__default.accessSync(candidate);
696
- return candidate;
697
- } catch {
903
+ function dedupeByUrl(entries) {
904
+ const logger = getLogger();
905
+ const byUrl = /* @__PURE__ */ new Map();
906
+ for (const entry of entries) {
907
+ const existing = byUrl.get(entry.url);
908
+ if (!existing) {
909
+ byUrl.set(entry.url, entry);
910
+ } else if (entry.specificity > existing.specificity) {
911
+ byUrl.set(entry.url, entry);
698
912
  }
699
913
  }
700
- return void 0;
701
- }
702
- function resolvePluginModule(pluginPath) {
703
- const pkgJsonPath = path__default.join(pluginPath, "package.json");
704
- try {
705
- const pkg = JSON.parse(fs__default.readFileSync(pkgJsonPath, "utf-8"));
706
- const mainExport = pkg.exports?.["."];
707
- const mainPath = typeof mainExport === "string" ? mainExport : mainExport?.default || mainExport?.import || pkg.main;
708
- if (mainPath) {
709
- const resolved = path__default.join(pluginPath, mainPath);
710
- if (fs__default.existsSync(resolved)) return resolved;
711
- }
712
- } catch {
914
+ const deduped = [...byUrl.values()];
915
+ if (deduped.length < entries.length) {
916
+ logger.info(
917
+ `[Build] Deduplication: ${entries.length} materialized → ${deduped.length} unique URLs`
918
+ );
713
919
  }
714
- return path__default.join(pluginPath, "dist", "index.js");
920
+ return deduped;
715
921
  }
716
- async function compileServerElement(jayHtmlContent, jayHtmlFilename, jayHtmlDir, outputPath, projectRoot, tsConfigFilePath, sourceDir, minifyCss = true) {
717
- const jayFile = await parseJayFile(
718
- jayHtmlContent,
719
- jayHtmlFilename,
720
- jayHtmlDir,
721
- { relativePath: tsConfigFilePath },
722
- JAY_IMPORT_RESOLVER,
723
- projectRoot,
724
- sourceDir
725
- );
726
- const parsedJayFile = checkValidationErrors(jayFile);
727
- const serverElementCode = checkValidationErrors(generateServerElementFile(parsedJayFile));
728
- const outputDir = path__default.dirname(outputPath);
729
- await fs.mkdir(outputDir, { recursive: true });
730
- const tsPath = outputPath.replace(/\.js$/, ".ts");
731
- await fs.writeFile(tsPath, serverElementCode, "utf-8");
732
- const jayOptions = { tsConfigFilePath };
733
- await build({
734
- root: projectRoot,
735
- publicDir: false,
736
- plugins: [jayRuntime(jayOptions)],
737
- build: {
738
- outDir: outputDir,
739
- emptyOutDir: false,
740
- minify: false,
741
- ssr: true,
742
- rollupOptions: {
743
- input: { [path__default.basename(outputPath, ".js")]: tsPath },
744
- external: [/^node:/, /^@jay-framework\//],
745
- output: {
746
- entryFileNames: "[name].js",
747
- format: "es"
748
- }
749
- }
922
+ function hashParams(params, suffix) {
923
+ const sorted = Object.keys(params).sort().reduce(
924
+ (acc, key) => {
925
+ acc[key] = params[key];
926
+ return acc;
750
927
  },
751
- logLevel: "warn"
752
- });
753
- await fs.rm(tsPath, { force: true });
754
- let cssFile;
755
- let cssImports;
756
- const css = parsedJayFile.css;
757
- if (css) {
758
- cssImports = extractCssImportUrls(css);
759
- const cssContent = minifyCss ? (await transform(css, { loader: "css", minify: true })).code : css;
760
- const hash = createHash("sha256").update(cssContent).digest("hex").slice(0, 8);
761
- const baseName = path__default.basename(outputPath, ".server-element.js");
762
- const cssFilename = `${baseName}-${hash}.css`;
763
- await fs.writeFile(path__default.join(outputDir, cssFilename), cssContent, "utf-8");
764
- cssFile = cssFilename;
765
- }
766
- getLogger().info(`[Build] Compiled server element: ${path__default.basename(outputPath)}`);
767
- return { cssFile, cssImports, headMeta: parsedJayFile.headMeta };
768
- }
769
- async function compileRouteServerElement(jayHtmlPath, outputPath, projectRoot, tsConfigFilePath, minifyCss = true) {
770
- const jayHtmlContent = await fs.readFile(jayHtmlPath, "utf-8");
771
- const sourceDir = path__default.dirname(jayHtmlPath);
772
- const outputDir = path__default.dirname(outputPath);
773
- const jayHtml = resolveJayHtmlPaths(jayHtmlContent, sourceDir, outputDir);
774
- return compileServerElement(
775
- jayHtml,
776
- path__default.basename(jayHtmlPath),
777
- outputDir,
778
- outputPath,
779
- projectRoot,
780
- tsConfigFilePath,
781
- sourceDir,
782
- minifyCss
928
+ {}
783
929
  );
930
+ const json = JSON.stringify(sorted);
931
+ if (json === "{}" && !suffix) return "";
932
+ const input = suffix ? json + ":" + suffix : json;
933
+ return "_" + crypto.createHash("md5").update(input).digest("hex").substring(0, 8);
784
934
  }
785
- async function compileRouteHydrateScript(jayHtmlPath, outputDir, projectRoot, tsConfigFilePath, minify = true) {
786
- const jayHtmlContent = await fs.readFile(jayHtmlPath, "utf-8");
787
- const sourceDir = path__default.dirname(jayHtmlPath);
788
- const jayHtml = resolveJayHtmlPaths(jayHtmlContent, sourceDir, outputDir);
789
- const jayFile = await parseJayFile(
790
- jayHtml,
791
- path__default.basename(jayHtmlPath),
792
- outputDir,
793
- { relativePath: tsConfigFilePath },
794
- JAY_IMPORT_RESOLVER,
795
- projectRoot,
796
- sourceDir
797
- );
798
- const parsedJayFile = checkValidationErrors(jayFile);
799
- const hydrateCode = checkValidationErrors(
800
- generateElementHydrateFile(parsedJayFile, RuntimeMode.MainTrusted)
935
+ async function buildInstance(route, params, pageModule, ctx, routeServerElementPath, routeCssPath, routeHydratePath, routeClientBundlePath) {
936
+ const logger = getLogger();
937
+ const routeDir = route.rawRoute.replace(/^\//, "") || "index";
938
+ const paramHash = hashParams(params, ctx.rebuildSuffix);
939
+ const instanceId = `page${paramHash}`;
940
+ const backendInstanceDir = path__default.join(ctx.backendDir, "pre-rendered", routeDir);
941
+ const frontendInstanceDir = path__default.join(ctx.frontendDir, "pages", routeDir);
942
+ await fs.mkdir(backendInstanceDir, { recursive: true });
943
+ await fs.mkdir(frontendInstanceDir, { recursive: true });
944
+ const jayHtmlContent = await fs.readFile(route.jayHtmlPath, "utf-8");
945
+ path__default.dirname(route.jayHtmlPath);
946
+ const serverBuildDir = path__default.join(ctx.backendDir, "server");
947
+ const pageParts = await loadProductionPageParts(
948
+ route,
949
+ pageModule,
950
+ jayHtmlContent,
951
+ ctx.projectRoot,
952
+ ctx.tsConfigFilePath,
953
+ serverBuildDir
801
954
  );
802
- await fs.mkdir(outputDir, { recursive: true });
803
- const tsPath = path__default.join(outputDir, "route.hydrate.ts");
804
- await fs.writeFile(tsPath, hydrateCode, "utf-8");
805
- const jayOptions = { tsConfigFilePath };
806
- await build({
807
- root: projectRoot,
808
- publicDir: false,
809
- plugins: [...jayStackCompiler(jayOptions)],
810
- build: {
811
- outDir: outputDir,
812
- emptyOutDir: false,
813
- minify,
814
- manifest: "route-hydrate-manifest.json",
815
- rollupOptions: {
816
- input: { "route.hydrate": tsPath },
817
- external: (id) => id.startsWith("@jay-framework/"),
818
- output: {
819
- entryFileNames: "[name]-[hash].js",
820
- format: "es"
821
- },
822
- preserveEntrySignatures: "exports-only"
823
- }
824
- },
825
- logLevel: "warn"
826
- });
827
- await fs.rm(tsPath, { force: true });
828
- const manifestPath = path__default.join(outputDir, "route-hydrate-manifest.json");
829
- const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8"));
830
- await fs.rm(manifestPath, { force: true });
831
- const entryKey = Object.keys(manifest).find((k) => manifest[k].isEntry);
832
- if (!entryKey) throw new Error("No entry in route hydrate manifest");
833
- const jsFile = manifest[entryKey].file;
834
- getLogger().info(`[Build] Compiled route hydrate script: ${jsFile}`);
835
- return { jsFile };
836
- }
837
- function resolveJayHtmlPaths(html, sourceDir, targetDir) {
838
- const root = parse(html, {
839
- comment: true,
840
- blockTextElements: { script: true, style: true }
841
- });
842
- const rewrite = (el, attr) => {
843
- const val = el.getAttribute(attr);
844
- if (val && (val.startsWith("./") || val.startsWith("../"))) {
845
- const abs = path__default.resolve(sourceDir, val);
846
- let rel = path__default.relative(targetDir, abs);
847
- if (!rel.startsWith(".")) rel = "./" + rel;
848
- el.setAttribute(attr, rel);
955
+ const contracts = [
956
+ .../* @__PURE__ */ new Set([
957
+ ...pageParts.headlessInstanceComponents.map((c) => c.contractName),
958
+ ...pageParts.parts.filter((p) => p.contractInfo?.contractName).map((p) => p.contractInfo.contractName)
959
+ ])
960
+ ];
961
+ const pagePartsConfigPath = path__default.join(backendInstanceDir, "page-parts.json");
962
+ const exportName = route.componentExport || "page";
963
+ let pageServerModule = "";
964
+ let pageIsPlugin = false;
965
+ if (route.compPath) {
966
+ if (route.componentExport) {
967
+ pageServerModule = route.packageName || route.compPath;
968
+ pageIsPlugin = true;
969
+ } else {
970
+ const relativePath = path__default.relative(ctx.projectRoot, route.compPath);
971
+ pageServerModule = relativePath.replace(/^src\//, "server/").replace(/\.ts$/, ".js").replace(/\[/g, "_").replace(/\]/g, "_");
849
972
  }
850
- };
851
- for (const el of root.querySelectorAll('script[type="application/jay-data"]')) {
852
- rewrite(el, "contract");
853
- }
854
- for (const el of root.querySelectorAll('script[type="application/jay-headless"]')) {
855
- rewrite(el, "src");
856
- rewrite(el, "contract");
857
973
  }
858
- for (const el of root.querySelectorAll('script[type="application/jay-headfull"]')) {
859
- rewrite(el, "src");
860
- rewrite(el, "contract");
861
- }
862
- for (const el of root.querySelectorAll('link[rel="stylesheet"]')) {
863
- rewrite(el, "href");
864
- }
865
- return root.toString();
866
- }
867
- function extractCssImportUrls(css) {
868
- const imports = [];
869
- const re = /@import\s*(?:url\(\s*['"]?([^'")]+)['"]?\s*\)|['"]([^'"]+)['"])/g;
870
- let match;
871
- while ((match = re.exec(css)) !== null) {
872
- const url = match[1] || match[2];
873
- if (url.startsWith("https://")) {
874
- imports.push(url);
974
+ const config = buildPagePartsConfig(
975
+ pageParts,
976
+ pageServerModule,
977
+ exportName,
978
+ ctx.backendDir,
979
+ pageIsPlugin
980
+ );
981
+ await fs.writeFile(pagePartsConfigPath, JSON.stringify(config, null, 2));
982
+ logger.info(`[Build] Page parts config: ${routeDir}/page-parts.json`);
983
+ const pageUrl = buildUrl(route, params);
984
+ const slowPhase = new DevSlowlyChangingPhase();
985
+ const slowResult = await slowPhase.runSlowlyForPage(
986
+ params,
987
+ { language: "en", url: pageUrl },
988
+ pageParts.parts,
989
+ pageParts.discoveredInstances,
990
+ pageParts.headlessInstanceComponents,
991
+ route.jayHtmlPath
992
+ );
993
+ if (slowResult.kind !== "PhaseOutput") {
994
+ if (slowResult.kind === "ClientError" || slowResult.kind === "Redirect") {
995
+ return {
996
+ status: "skipped",
997
+ reason: `${slowResult.kind} ${slowResult.status ?? ""} ${slowResult.message ?? ""}`.trim()
998
+ };
875
999
  }
1000
+ throw new Error(
1001
+ `Slow render failed for ${route.rawRoute} with params ${JSON.stringify(params)}: ${slowResult.kind}`
1002
+ );
876
1003
  }
877
- return imports;
878
- }
879
- async function generateRouteHydrationEntry(options) {
880
- const {
881
- hydrateImport,
882
- pageModulePath,
883
- pageExportName = "page",
884
- trackByMap,
885
- outputPath,
886
- keyedParts = [],
887
- clientInits = []
888
- } = options;
889
- const partImports = keyedParts.map((p, i) => `import { ${p.exportName} as keyedPart${i} } from '${p.modulePath}';`).join("\n");
890
- const hasPageModule = pageModulePath && pageExportName;
891
- const pagePartExpr = hasPageModule ? `pagePart && pagePart.comp ? { comp: pagePart.comp, contextMarkers: pagePart.contexts || [] } : null` : `null`;
892
- const partsArray = [
893
- pagePartExpr,
894
- ...keyedParts.map(
895
- (p, i) => `keyedPart${i} && keyedPart${i}.comp ? { comp: keyedPart${i}.comp, contextMarkers: keyedPart${i}.contexts || [], key: '${p.key}' } : null`
896
- )
897
- ];
898
- const pageImport = hasPageModule ? `import { ${pageExportName} as pagePart } from '${pageModulePath}';` : "";
899
- const initImports = clientInits.map((ci, i) => `import { ${ci.exportName} as clientInit${i} } from '${ci.modulePath}';`).join("\n");
900
- const initCalls = clientInits.map(
901
- (ci, i) => ` if (clientInit${i}?._clientInit) await clientInit${i}._clientInit(clientInitData['${ci.key}'] || {});`
902
- ).join("\n");
903
- const code = `import { hydrateCompositeJayComponent } from '@jay-framework/stack-client-runtime';
904
- import { deepMergeViewStates } from '@jay-framework/view-state-merge';
905
- import { hydrate } from '${hydrateImport}';
906
- ${pageImport}
907
- ${partImports}
908
- ${initImports}
909
-
910
- const trackByMap = ${JSON.stringify(trackByMap)};
911
-
912
- export async function init(slowViewState, fastViewState, fastCarryForward, clientInitData) {
913
- ${initCalls}
914
- const viewState = deepMergeViewStates(slowViewState, fastViewState, trackByMap);
915
- const target = document.getElementById('target');
916
- const rootElement = target.firstElementChild;
917
- const parts = [
918
- ${partsArray.join(",\n ")}
919
- ].filter(p => p !== null);
920
- const pageComp = hydrateCompositeJayComponent(
921
- hydrate, viewState, fastCarryForward,
922
- parts, trackByMap, rootElement
1004
+ const slowViewState = slowResult.rendered;
1005
+ const carryForward = slowResult.carryForward;
1006
+ if (pageParts.discoveredInstances.length > 0 && pageParts.headlessInstanceComponents.length > 0) {
1007
+ const slowResult2 = await slowRenderInstances(
1008
+ pageParts.discoveredInstances,
1009
+ pageParts.headlessInstanceComponents,
1010
+ {
1011
+ pageViewState: slowViewState,
1012
+ pageParams: params,
1013
+ pageProps: { language: "en", url: pageUrl }
1014
+ }
923
1015
  );
924
- return pageComp({});
925
- }
926
- `;
927
- const outputDir = path__default.dirname(outputPath);
928
- await fs.mkdir(outputDir, { recursive: true });
929
- await fs.writeFile(outputPath, code, "utf-8");
930
- getLogger().info(`[Build] Generated route hydration entry: ${path__default.basename(outputPath)}`);
931
- }
932
- async function buildInstanceClient(hydrateEntryPath, instanceId, outputDir, projectRoot, jayOptions, minify = true, pagesRoot, buildDir) {
933
- const logger = getLogger();
934
- await fs.mkdir(outputDir, { recursive: true });
935
- const fullJayOptions = {
936
- ...jayOptions,
937
- ...pagesRoot && buildDir ? { pagesRoot, buildFolder: buildDir } : {}
938
- };
939
- await build({
940
- root: projectRoot,
941
- publicDir: false,
942
- plugins: [...jayStackCompiler(fullJayOptions)],
943
- build: {
944
- outDir: outputDir,
945
- emptyOutDir: false,
946
- minify,
947
- manifest: `${instanceId}-manifest.json`,
948
- rollupOptions: {
949
- input: { [instanceId]: hydrateEntryPath },
950
- external: (id) => id.startsWith("@jay-framework/") || id === "jay-route-hydrate",
951
- output: {
952
- entryFileNames: "[name]-[hash].js",
953
- chunkFileNames: "chunks/[name]-[hash].js",
954
- assetFileNames: "[name]-[hash].[ext]",
955
- format: "es"
1016
+ if (slowResult2) {
1017
+ const existingInstances = carryForward.__instances || {
1018
+ discovered: [],
1019
+ carryForwards: {}
1020
+ };
1021
+ carryForward.__instances = {
1022
+ discovered: [
1023
+ ...existingInstances.discovered,
1024
+ ...slowResult2.instancePhaseData.discovered
1025
+ ],
1026
+ carryForwards: {
1027
+ ...existingInstances.carryForwards,
1028
+ ...slowResult2.instancePhaseData.carryForwards
956
1029
  },
957
- preserveEntrySignatures: "exports-only"
958
- }
959
- },
960
- logLevel: "warn"
1030
+ slowViewStates: {
1031
+ ...existingInstances.slowViewStates || {},
1032
+ ...slowResult2.instancePhaseData.slowViewStates
1033
+ }
1034
+ };
1035
+ }
1036
+ }
1037
+ if (pageParts.forEachInstances.length > 0) {
1038
+ const existingInstances = carryForward.__instances || {
1039
+ discovered: [],
1040
+ carryForwards: {}
1041
+ };
1042
+ existingInstances.forEachInstances = pageParts.forEachInstances;
1043
+ carryForward.__instances = existingInstances;
1044
+ }
1045
+ const cachePath = path__default.join(backendInstanceDir, `${instanceId}.cache.json`);
1046
+ await fs.writeFile(
1047
+ cachePath,
1048
+ JSON.stringify({
1049
+ slowViewState,
1050
+ carryForward
1051
+ }),
1052
+ "utf-8"
1053
+ );
1054
+ logger.info(`[Build] Instance data: ${routeDir}/${instanceId}`);
1055
+ const serverElementPath = routeServerElementPath ? path__default.join(ctx.backendDir, routeServerElementPath) : path__default.join(backendInstanceDir, `${instanceId}.server-element.js`);
1056
+ const instanceEntry = {
1057
+ params,
1058
+ cachePath: path__default.relative(ctx.backendDir, cachePath),
1059
+ serverElementPath: path__default.relative(ctx.backendDir, serverElementPath),
1060
+ clientBundlePath: routeClientBundlePath || "",
1061
+ clientCssPath: routeCssPath
1062
+ };
1063
+ return { status: "success", instanceEntry, slowViewState, carryForward, contracts };
1064
+ }
1065
+ function convertSegments(segments) {
1066
+ return segments.map((s) => {
1067
+ if (typeof s === "string") {
1068
+ return { type: "static", value: s };
1069
+ }
1070
+ switch (s.type) {
1071
+ case JayRouteParamType.single:
1072
+ return { type: "param", value: s.name };
1073
+ case JayRouteParamType.catchAll:
1074
+ return { type: "catchAll", value: s.name };
1075
+ case JayRouteParamType.optional:
1076
+ return { type: "optional", value: s.name };
1077
+ }
961
1078
  });
962
- const manifestPath = path__default.join(outputDir, `${instanceId}-manifest.json`);
963
- const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8"));
964
- await fs.rm(manifestPath, { force: true });
965
- const entryKey = Object.keys(manifest).find((k) => manifest[k].isEntry);
966
- if (!entryKey) {
967
- throw new Error(`No entry found in instance build manifest for ${instanceId}`);
968
- }
969
- const entry = manifest[entryKey];
970
- const result = {
971
- jsFile: entry.file,
972
- cssFile: entry.css?.[0]
1079
+ }
1080
+ function buildRouteEntry(route, serverModulePath) {
1081
+ return {
1082
+ pattern: route.rawRoute,
1083
+ segments: convertSegments(route.segments),
1084
+ serverModule: serverModulePath,
1085
+ componentExport: route.componentExport,
1086
+ instances: [],
1087
+ ...route.devOnly && { devOnly: true }
973
1088
  };
974
- logger.info(`[Build] Client bundle: ${result.jsFile}`);
975
- return result;
976
1089
  }
977
- function crossProductParams(parts) {
978
- if (parts.length === 0) return [];
979
- if (parts.length === 1) return parts[0].values;
980
- const logger = getLogger();
981
- for (let i = 0; i < parts.length; i++) {
982
- for (let j = i + 1; j < parts.length; j++) {
983
- for (const key of parts[i].keys) {
984
- if (parts[j].keys.has(key)) {
985
- logger.warn(
986
- `[Build] Multiple loadParams provide key "${key}" — using first provider`
987
- );
988
- }
1090
+ async function discoverActions(actionPaths, serverOutputDir, buildDir, projectRoot) {
1091
+ const actions = [];
1092
+ const plugins = [];
1093
+ for (const [entryName, sourcePath] of Object.entries(actionPaths)) {
1094
+ try {
1095
+ const code = await fs.readFile(sourcePath, "utf-8");
1096
+ const extracted = extractActionsFromSource(code, sourcePath);
1097
+ if (extracted.length > 0) {
1098
+ actions.push({
1099
+ serverModule: path__default.relative(
1100
+ buildDir,
1101
+ path__default.join(serverOutputDir, entryName + ".js")
1102
+ ),
1103
+ isPlugin: false,
1104
+ actionNames: extracted.map((a) => a.actionName)
1105
+ });
989
1106
  }
1107
+ } catch {
1108
+ getLogger().warn(`[Build] Could not extract actions from ${sourcePath}`);
990
1109
  }
991
1110
  }
992
- let result = parts[0].values;
993
- for (let i = 1; i < parts.length; i++) {
994
- const next = parts[i].values;
995
- const combined = [];
996
- for (const a of result) {
997
- for (const b of next) {
998
- combined.push({ ...a, ...b });
1111
+ try {
1112
+ const scannedPlugins = await scanPlugins({ projectRoot });
1113
+ for (const [packageName, plugin] of scannedPlugins) {
1114
+ if (plugin.isLocal) continue;
1115
+ plugins.push({ name: plugin.manifest.name, packageName });
1116
+ const pluginActions = plugin.manifest.actions;
1117
+ if (pluginActions && pluginActions.length > 0) {
1118
+ const productionActions = pluginActions.map(normalizeActionEntry).filter((a) => !a.devOnly);
1119
+ const devOnlyCount = pluginActions.length - productionActions.length;
1120
+ if (productionActions.length > 0) {
1121
+ actions.push({
1122
+ serverModule: "",
1123
+ packageName,
1124
+ isPlugin: true,
1125
+ actionNames: productionActions.map((a) => a.name)
1126
+ });
1127
+ }
1128
+ getLogger().info(
1129
+ `[Build] Plugin actions from ${packageName}: ${productionActions.length}` + (devOnlyCount > 0 ? ` (excluded ${devOnlyCount} devOnly)` : "")
1130
+ );
999
1131
  }
1000
1132
  }
1001
- result = combined;
1133
+ } catch (err) {
1134
+ getLogger().warn(`[Build] Plugin action scan failed: ${err.message}`);
1002
1135
  }
1003
- return result;
1004
- }
1005
- function paramsMatchInferred(params, inferredParams, optionalSegments) {
1006
- return Object.entries(inferredParams).every(([k, v]) => {
1007
- if (optionalSegments?.has(k)) return true;
1008
- return params[k] === v;
1009
- });
1010
- }
1011
- function computeSpecificity(route) {
1012
- const dynamicCount = (route.rawRoute.match(/\[/g) || []).length;
1013
- const inferredCount = route.inferredParams ? Object.keys(route.inferredParams).length : 0;
1014
- const unresolvedCount = Math.max(0, dynamicCount - inferredCount);
1015
- return 0 - unresolvedCount;
1136
+ return { actions, plugins };
1016
1137
  }
1017
- function buildUrl(route, params) {
1018
- return route.rawRoute.replace(/\[\[(\w+)\]\]/g, (_, name) => {
1019
- const value = params[name];
1020
- if (!value) return "";
1021
- if (route.inferredParams?.[name] === value) return "";
1022
- return value;
1023
- }).replace(/\[(\w+)\]/g, (_, name) => params[name] || "").replace(/\/\/+/g, "/").replace(/\/$/, "") || "/";
1138
+ async function writeRouteManifest(manifest, buildDir) {
1139
+ const manifestPath = path__default.join(buildDir, "route-manifest.json");
1140
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
1141
+ getLogger().info(
1142
+ `[Build] Route manifest written: ${manifest.routes.length} routes, ${manifest.routes.reduce((n, r) => n + r.instances.length, 0)} instances`
1143
+ );
1024
1144
  }
1025
- function materializeRouteParams(routes, loadParamsResults) {
1026
- const entries = [];
1027
- for (const route of routes) {
1028
- const specificity = computeSpecificity(route);
1029
- if (!route.hasDynamicParams) {
1030
- const params = route.inferredParams || {};
1031
- entries.push({ route, params, url: route.rawRoute, specificity });
1032
- continue;
1033
- }
1034
- const allParams = loadParamsResults.get(route) || [];
1035
- for (const params of allParams) {
1036
- if (route.inferredParams && !paramsMatchInferred(params, route.inferredParams, route.optionalSegments)) {
1145
+ createRequire(import.meta.url);
1146
+ async function scanPluginRoutes(projectRoot, projectRoutes) {
1147
+ const logger = getLogger();
1148
+ const plugins = await scanPlugins({ projectRoot });
1149
+ const projectPaths = new Set(projectRoutes.map((r) => r.rawRoute));
1150
+ const pluginRoutes = [];
1151
+ for (const [, plugin] of plugins) {
1152
+ if (plugin.isLocal) continue;
1153
+ if (!plugin.manifest.routes) continue;
1154
+ for (const route of plugin.manifest.routes) {
1155
+ if (projectPaths.has(route.path)) {
1156
+ logger.info(
1157
+ `[Routes] Plugin "${plugin.manifest.name}" route ${route.path} skipped — project route takes precedence`
1158
+ );
1037
1159
  continue;
1038
1160
  }
1039
- let mergedParams = params;
1040
- if (route.inferredParams) {
1041
- mergedParams = { ...params };
1042
- for (const [k, v] of Object.entries(route.inferredParams)) {
1043
- if (!(k in mergedParams)) {
1044
- mergedParams[k] = v;
1045
- }
1046
- }
1161
+ const jayHtmlPath = resolvePluginExport(plugin.pluginPath, route.jayHtml);
1162
+ if (!jayHtmlPath) {
1163
+ logger.warn(
1164
+ `[Routes] Plugin "${plugin.manifest.name}" route ${route.path}: jayHtml "${route.jayHtml}" not found`
1165
+ );
1166
+ continue;
1047
1167
  }
1048
- const url = buildUrl(route, mergedParams);
1049
- entries.push({ route, params: mergedParams, url, specificity });
1168
+ const compPath = resolvePluginModule(plugin.pluginPath);
1169
+ const componentExport = route.component;
1170
+ pluginRoutes.push({
1171
+ segments: parseRouteSegments(route.path),
1172
+ rawRoute: route.path,
1173
+ jayHtmlPath,
1174
+ compPath,
1175
+ componentExport,
1176
+ packageName: plugin.packageName,
1177
+ ...route.devOnly === true && { devOnly: true }
1178
+ });
1179
+ logger.info(
1180
+ `[Routes] Plugin "${plugin.manifest.name}" provides route ${route.path}${route.devOnly ? " (dev-only)" : ""}`
1181
+ );
1050
1182
  }
1051
1183
  }
1052
- return entries;
1184
+ return pluginRoutes;
1053
1185
  }
1054
- function dedupeByUrl(entries) {
1055
- const logger = getLogger();
1056
- const byUrl = /* @__PURE__ */ new Map();
1057
- for (const entry of entries) {
1058
- const existing = byUrl.get(entry.url);
1059
- if (!existing) {
1060
- byUrl.set(entry.url, entry);
1061
- } else if (entry.specificity > existing.specificity) {
1062
- byUrl.set(entry.url, entry);
1186
+ function resolvePluginExport(pluginPath, exportSubpath) {
1187
+ const normalized = exportSubpath.replace(/^\.\//, "");
1188
+ const packageJsonPath = path__default.join(pluginPath, "package.json");
1189
+ try {
1190
+ const packageJson = JSON.parse(fs__default.readFileSync(packageJsonPath, "utf-8"));
1191
+ if (packageJson.exports) {
1192
+ const exportKey = "./" + normalized;
1193
+ const exportValue = packageJson.exports[exportKey];
1194
+ if (exportValue) {
1195
+ const resolved = typeof exportValue === "string" ? exportValue : exportValue.default || exportValue.import || exportValue.require;
1196
+ if (resolved) return path__default.join(pluginPath, resolved);
1197
+ }
1063
1198
  }
1199
+ } catch {
1064
1200
  }
1065
- const deduped = [...byUrl.values()];
1066
- if (deduped.length < entries.length) {
1067
- logger.info(
1068
- `[Build] Deduplication: ${entries.length} materialized → ${deduped.length} unique URLs`
1069
- );
1201
+ for (const dir of ["dist", "lib", ""]) {
1202
+ const candidate = path__default.join(pluginPath, dir, normalized);
1203
+ try {
1204
+ fs__default.accessSync(candidate);
1205
+ return candidate;
1206
+ } catch {
1207
+ }
1070
1208
  }
1071
- return deduped;
1209
+ return void 0;
1210
+ }
1211
+ function resolvePluginModule(pluginPath) {
1212
+ const pkgJsonPath = path__default.join(pluginPath, "package.json");
1213
+ try {
1214
+ const pkg = JSON.parse(fs__default.readFileSync(pkgJsonPath, "utf-8"));
1215
+ const mainExport = pkg.exports?.["."];
1216
+ const mainPath = typeof mainExport === "string" ? mainExport : mainExport?.default || mainExport?.import || pkg.main;
1217
+ if (mainPath) {
1218
+ const resolved = path__default.join(pluginPath, mainPath);
1219
+ if (fs__default.existsSync(resolved)) return resolved;
1220
+ }
1221
+ } catch {
1222
+ }
1223
+ return path__default.join(pluginPath, "dist", "index.js");
1072
1224
  }
1073
1225
  async function discoverPluginClientPackages(projectRoot) {
1074
1226
  const projectRequire = createRequire(path__default.join(projectRoot, "package.json"));
@@ -1147,7 +1299,10 @@ async function buildVersion(options) {
1147
1299
  logger.important(`[Build] Project: ${options.projectRoot}`);
1148
1300
  await fs.mkdir(backendDir, { recursive: true });
1149
1301
  await fs.mkdir(frontendDir, { recursive: true });
1150
- const { entries, routes } = await discoverServerEntries(options.projectRoot, options.pagesRoot);
1302
+ const { entries, routes, errorPages } = await discoverServerEntries(
1303
+ options.projectRoot,
1304
+ options.pagesRoot
1305
+ );
1151
1306
  const serverOutputDir = path__default.join(backendDir, "server");
1152
1307
  await buildServerCode(
1153
1308
  entries,
@@ -1404,6 +1559,9 @@ async function buildVersion(options) {
1404
1559
  entry.noIndex = true;
1405
1560
  }
1406
1561
  }
1562
+ if (seResult.scripts?.length) {
1563
+ entry.scripts = seResult.scripts;
1564
+ }
1407
1565
  logger.important(`[Build] Route server element: ${routeDir}`);
1408
1566
  } catch (err) {
1409
1567
  logger.error(`[Build] Route server element FAILED ${route.rawRoute}: ${err.message}`);
@@ -1545,13 +1703,33 @@ async function buildVersion(options) {
1545
1703
  }
1546
1704
  }
1547
1705
  }
1706
+ let errorPageEntries;
1707
+ if (errorPages.length > 0) {
1708
+ errorPageEntries = await buildErrorPages(
1709
+ errorPages,
1710
+ instanceCtx,
1711
+ options.projectRoot,
1712
+ options.pagesRoot,
1713
+ options.tsConfigFilePath,
1714
+ options.minify ?? true,
1715
+ sharedManifest,
1716
+ buildDir
1717
+ );
1718
+ logger.important(
1719
+ `[Build] Error pages compiled: ${Object.keys(errorPageEntries).join(", ")}`
1720
+ );
1721
+ }
1722
+ const siteConfig = options.site ?? (options.siteBaseUrl ? { baseUrl: options.siteBaseUrl } : void 0);
1723
+ const siteHeadTags = generateSiteJsonLd(siteConfig);
1548
1724
  const manifest = {
1549
1725
  version: options.version,
1550
1726
  projectRoot: options.projectRoot,
1551
1727
  sharedManifest,
1552
1728
  routes: routeEntries.map((r) => r.entry),
1553
1729
  actions,
1554
- plugins
1730
+ plugins,
1731
+ ...siteHeadTags.length > 0 && { siteHeadTags },
1732
+ ...errorPageEntries && Object.keys(errorPageEntries).length > 0 && { errorPages: errorPageEntries }
1555
1733
  };
1556
1734
  await writeRouteManifest(manifest, backendDir);
1557
1735
  const publicFolder = path__default.join(options.projectRoot, "public");
@@ -1566,6 +1744,24 @@ async function buildVersion(options) {
1566
1744
  const urlCount = await generateSitemap(manifest, options.siteBaseUrl, sitemapPath);
1567
1745
  logger.important(`[Build] Sitemap generated: ${urlCount} URLs`);
1568
1746
  }
1747
+ const robotsWritten = await generateRobotsTxtIfAbsent(
1748
+ path__default.join(frontendDir, "robots.txt"),
1749
+ siteConfig?.baseUrl
1750
+ );
1751
+ if (robotsWritten) logger.important("[Build] robots.txt generated");
1752
+ if (siteConfig?.name) {
1753
+ const llmsPath = path__default.join(frontendDir, "llms.txt");
1754
+ let authored = false;
1755
+ try {
1756
+ await fs.access(llmsPath);
1757
+ authored = true;
1758
+ } catch {
1759
+ }
1760
+ if (!authored) {
1761
+ const count = await generateLlmsTxt(manifest, siteConfig, backendDir, llmsPath);
1762
+ logger.important(`[Build] llms.txt generated: ${count} pages`);
1763
+ }
1764
+ }
1569
1765
  const sourceHash = await computeBuildHash(buildDir);
1570
1766
  const metadata = {
1571
1767
  version: options.version,