@jay-framework/jay-stack-cli 0.25.1 → 0.25.3

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,123 +152,310 @@ 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);
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);
265
459
  compiledPath = indexPath;
266
460
  } catch {
267
461
  compiledPath += ".js";
@@ -411,69 +605,287 @@ function buildPagePartsConfig(pageParts, pageServerModule, pageExportName, build
411
605
  }))
412
606
  };
413
607
  }
414
- function crossProductParams(parts) {
415
- if (parts.length === 0) return [];
416
- if (parts.length === 1) return parts[0].values;
608
+ async function buildErrorPages(errorPages, ctx, projectRoot, pagesRoot, tsConfigFilePath, minify, sharedManifest, buildDir) {
417
609
  const logger = getLogger();
418
- for (let i = 0; i < parts.length; i++) {
419
- for (let j = i + 1; j < parts.length; j++) {
420
- for (const key of parts[i].keys) {
421
- if (parts[j].keys.has(key)) {
422
- logger.warn(
423
- `[Build] Multiple loadParams provide key "${key}" — using first provider`
424
- );
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;
615
+ }
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 });
425
643
  }
644
+ entry.routeCssPath = path__default.relative(ctx.frontendDir, dstCss);
426
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;
427
655
  }
428
- }
429
- let result = parts[0].values;
430
- for (let i = 1; i < parts.length; i++) {
431
- const next = parts[i].values;
432
- const combined = [];
433
- for (const a of result) {
434
- for (const b of next) {
435
- combined.push({ ...a, ...b });
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 {
436
677
  }
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;
437
685
  }
438
- result = combined;
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;
439
744
  }
440
745
  return result;
441
746
  }
442
- function paramsMatchInferred(params, inferredParams, optionalSegments) {
443
- return Object.entries(inferredParams).every(([k, v]) => {
444
- if (optionalSegments?.has(k)) return true;
445
- return params[k] === v;
446
- });
447
- }
448
- function computeSpecificity(route) {
449
- const dynamicCount = (route.rawRoute.match(/\[/g) || []).length;
450
- const inferredCount = route.inferredParams ? Object.keys(route.inferredParams).length : 0;
451
- const unresolvedCount = Math.max(0, dynamicCount - inferredCount);
452
- return 0 - unresolvedCount;
453
- }
454
- function buildUrl(route, params) {
455
- return route.rawRoute.replace(/\[\[(\w+)\]\]/g, (_, name) => {
456
- const value = params[name];
457
- if (!value) return "";
458
- if (route.inferredParams?.[name] === value) return "";
459
- return value;
460
- }).replace(/\[(\w+)\]/g, (_, name) => params[name] || "").replace(/\/\/+/g, "/").replace(/\/$/, "") || "/";
461
- }
462
- function materializeRouteParams(routes, loadParamsResults) {
463
- const entries = [];
464
- for (const route of routes) {
465
- const specificity = computeSpecificity(route);
466
- if (!route.hasDynamicParams) {
467
- const params = route.inferredParams || {};
468
- entries.push({ route, params, url: route.rawRoute, specificity });
469
- continue;
470
- }
471
- const allParams = loadParamsResults.get(route) || [];
472
- for (const params of allParams) {
473
- if (route.inferredParams && !paramsMatchInferred(params, route.inferredParams, route.optionalSegments)) {
474
- continue;
475
- }
476
- let mergedParams = params;
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"
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 };
799
+ }
800
+ function pkgToVarName(pkg) {
801
+ return pkg.replace("@jay-framework/", "").replace(/[/-]/g, "_");
802
+ }
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;
818
+ }
819
+ }
820
+ }
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
+ );
837
+ }
838
+ }
839
+ }
840
+ }
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;
853
+ }
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
+ });
859
+ }
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)) {
886
+ continue;
887
+ }
888
+ let mergedParams = params;
477
889
  if (route.inferredParams) {
478
890
  mergedParams = { ...params };
479
891
  for (const [k, v] of Object.entries(route.inferredParams)) {
@@ -675,406 +1087,140 @@ function buildRouteEntry(route, serverModulePath) {
675
1087
  ...route.devOnly && { devOnly: true }
676
1088
  };
677
1089
  }
678
- async function discoverActions(actionPaths, serverOutputDir, buildDir, projectRoot) {
679
- const actions = [];
680
- const plugins = [];
681
- for (const [entryName, sourcePath] of Object.entries(actionPaths)) {
682
- try {
683
- const code = await fs.readFile(sourcePath, "utf-8");
684
- const extracted = extractActionsFromSource(code, sourcePath);
685
- if (extracted.length > 0) {
686
- actions.push({
687
- serverModule: path__default.relative(
688
- buildDir,
689
- path__default.join(serverOutputDir, entryName + ".js")
690
- ),
691
- isPlugin: false,
692
- actionNames: extracted.map((a) => a.actionName)
693
- });
694
- }
695
- } catch {
696
- getLogger().warn(`[Build] Could not extract actions from ${sourcePath}`);
697
- }
698
- }
699
- try {
700
- const scannedPlugins = await scanPlugins({ projectRoot });
701
- for (const [packageName, plugin] of scannedPlugins) {
702
- if (plugin.isLocal) continue;
703
- plugins.push({ name: plugin.manifest.name, packageName });
704
- const pluginActions = plugin.manifest.actions;
705
- if (pluginActions && pluginActions.length > 0) {
706
- const productionActions = pluginActions.map(normalizeActionEntry).filter((a) => !a.devOnly);
707
- const devOnlyCount = pluginActions.length - productionActions.length;
708
- if (productionActions.length > 0) {
709
- actions.push({
710
- serverModule: "",
711
- packageName,
712
- isPlugin: true,
713
- actionNames: productionActions.map((a) => a.name)
714
- });
715
- }
716
- getLogger().info(
717
- `[Build] Plugin actions from ${packageName}: ${productionActions.length}` + (devOnlyCount > 0 ? ` (excluded ${devOnlyCount} devOnly)` : "")
718
- );
719
- }
720
- }
721
- } catch (err) {
722
- getLogger().warn(`[Build] Plugin action scan failed: ${err.message}`);
723
- }
724
- return { actions, plugins };
725
- }
726
- async function writeRouteManifest(manifest, buildDir) {
727
- const manifestPath = path__default.join(buildDir, "route-manifest.json");
728
- await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
729
- getLogger().info(
730
- `[Build] Route manifest written: ${manifest.routes.length} routes, ${manifest.routes.reduce((n, r) => n + r.instances.length, 0)} instances`
731
- );
732
- }
733
- createRequire(import.meta.url);
734
- async function scanPluginRoutes(projectRoot, projectRoutes) {
735
- const logger = getLogger();
736
- const plugins = await scanPlugins({ projectRoot });
737
- const projectPaths = new Set(projectRoutes.map((r) => r.rawRoute));
738
- const pluginRoutes = [];
739
- for (const [, plugin] of plugins) {
740
- if (plugin.isLocal) continue;
741
- if (!plugin.manifest.routes) continue;
742
- for (const route of plugin.manifest.routes) {
743
- if (projectPaths.has(route.path)) {
744
- logger.info(
745
- `[Routes] Plugin "${plugin.manifest.name}" route ${route.path} skipped — project route takes precedence`
746
- );
747
- continue;
748
- }
749
- const jayHtmlPath = resolvePluginExport(plugin.pluginPath, route.jayHtml);
750
- if (!jayHtmlPath) {
751
- logger.warn(
752
- `[Routes] Plugin "${plugin.manifest.name}" route ${route.path}: jayHtml "${route.jayHtml}" not found`
753
- );
754
- continue;
755
- }
756
- const compPath = resolvePluginModule(plugin.pluginPath);
757
- const componentExport = route.component;
758
- pluginRoutes.push({
759
- segments: parseRouteSegments(route.path),
760
- rawRoute: route.path,
761
- jayHtmlPath,
762
- compPath,
763
- componentExport,
764
- packageName: plugin.packageName,
765
- ...route.devOnly === true && { devOnly: true }
766
- });
767
- logger.info(
768
- `[Routes] Plugin "${plugin.manifest.name}" provides route ${route.path}${route.devOnly ? " (dev-only)" : ""}`
769
- );
770
- }
771
- }
772
- return pluginRoutes;
773
- }
774
- function resolvePluginExport(pluginPath, exportSubpath) {
775
- const normalized = exportSubpath.replace(/^\.\//, "");
776
- const packageJsonPath = path__default.join(pluginPath, "package.json");
777
- try {
778
- const packageJson = JSON.parse(fs__default.readFileSync(packageJsonPath, "utf-8"));
779
- if (packageJson.exports) {
780
- const exportKey = "./" + normalized;
781
- const exportValue = packageJson.exports[exportKey];
782
- if (exportValue) {
783
- const resolved = typeof exportValue === "string" ? exportValue : exportValue.default || exportValue.import || exportValue.require;
784
- if (resolved) return path__default.join(pluginPath, resolved);
785
- }
786
- }
787
- } catch {
788
- }
789
- for (const dir of ["dist", "lib", ""]) {
790
- const candidate = path__default.join(pluginPath, dir, normalized);
791
- try {
792
- fs__default.accessSync(candidate);
793
- return candidate;
794
- } catch {
795
- }
796
- }
797
- return void 0;
798
- }
799
- function resolvePluginModule(pluginPath) {
800
- const pkgJsonPath = path__default.join(pluginPath, "package.json");
801
- try {
802
- const pkg = JSON.parse(fs__default.readFileSync(pkgJsonPath, "utf-8"));
803
- const mainExport = pkg.exports?.["."];
804
- const mainPath = typeof mainExport === "string" ? mainExport : mainExport?.default || mainExport?.import || pkg.main;
805
- if (mainPath) {
806
- const resolved = path__default.join(pluginPath, mainPath);
807
- if (fs__default.existsSync(resolved)) return resolved;
808
- }
809
- } catch {
810
- }
811
- return path__default.join(pluginPath, "dist", "index.js");
812
- }
813
- async function compileServerElement(jayHtmlContent, jayHtmlFilename, jayHtmlDir, outputPath, projectRoot, tsConfigFilePath, sourceDir, minifyCss = true) {
814
- const jayFile = await parseJayFile(
815
- jayHtmlContent,
816
- jayHtmlFilename,
817
- jayHtmlDir,
818
- { relativePath: tsConfigFilePath },
819
- JAY_IMPORT_RESOLVER,
820
- projectRoot,
821
- sourceDir
822
- );
823
- const parsedJayFile = checkValidationErrors(jayFile);
824
- const serverElementCode = checkValidationErrors(generateServerElementFile(parsedJayFile));
825
- const outputDir = path__default.dirname(outputPath);
826
- await fs.mkdir(outputDir, { recursive: true });
827
- const tsPath = outputPath.replace(/\.js$/, ".ts");
828
- await fs.writeFile(tsPath, serverElementCode, "utf-8");
829
- const jayOptions = { tsConfigFilePath };
830
- await build({
831
- root: projectRoot,
832
- publicDir: false,
833
- plugins: [jayRuntime(jayOptions)],
834
- build: {
835
- outDir: outputDir,
836
- emptyOutDir: false,
837
- minify: false,
838
- ssr: true,
839
- rollupOptions: {
840
- input: { [path__default.basename(outputPath, ".js")]: tsPath },
841
- external: [/^node:/, /^@jay-framework\//],
842
- output: {
843
- entryFileNames: "[name].js",
844
- format: "es"
845
- }
846
- }
847
- },
848
- logLevel: "warn"
849
- });
850
- await fs.rm(tsPath, { force: true });
851
- let cssFile;
852
- let cssImports;
853
- const css = parsedJayFile.css;
854
- if (css) {
855
- cssImports = extractCssImportUrls(css);
856
- const cssContent = minifyCss ? (await transform(css, { loader: "css", minify: true })).code : css;
857
- const hash = createHash("sha256").update(cssContent).digest("hex").slice(0, 8);
858
- const baseName = path__default.basename(outputPath, ".server-element.js");
859
- const cssFilename = `${baseName}-${hash}.css`;
860
- await fs.writeFile(path__default.join(outputDir, cssFilename), cssContent, "utf-8");
861
- cssFile = cssFilename;
862
- }
863
- getLogger().info(`[Build] Compiled server element: ${path__default.basename(outputPath)}`);
864
- return {
865
- cssFile,
866
- cssImports,
867
- headMeta: parsedJayFile.headMeta,
868
- scripts: parsedJayFile.scripts
869
- };
870
- }
871
- async function compileRouteServerElement(jayHtmlPath, outputPath, projectRoot, tsConfigFilePath, minifyCss = true) {
872
- const jayHtmlContent = await fs.readFile(jayHtmlPath, "utf-8");
873
- const sourceDir = path__default.dirname(jayHtmlPath);
874
- const outputDir = path__default.dirname(outputPath);
875
- const jayHtml = resolveJayHtmlPaths(jayHtmlContent, sourceDir, outputDir);
876
- return compileServerElement(
877
- jayHtml,
878
- path__default.basename(jayHtmlPath),
879
- outputDir,
880
- outputPath,
881
- projectRoot,
882
- tsConfigFilePath,
883
- sourceDir,
884
- minifyCss
885
- );
886
- }
887
- async function compileRouteHydrateScript(jayHtmlPath, outputDir, projectRoot, tsConfigFilePath, minify = true) {
888
- const jayHtmlContent = await fs.readFile(jayHtmlPath, "utf-8");
889
- const sourceDir = path__default.dirname(jayHtmlPath);
890
- const jayHtml = resolveJayHtmlPaths(jayHtmlContent, sourceDir, outputDir);
891
- const jayFile = await parseJayFile(
892
- jayHtml,
893
- path__default.basename(jayHtmlPath),
894
- outputDir,
895
- { relativePath: tsConfigFilePath },
896
- JAY_IMPORT_RESOLVER,
897
- projectRoot,
898
- sourceDir
899
- );
900
- const parsedJayFile = checkValidationErrors(jayFile);
901
- const hydrateCode = checkValidationErrors(
902
- generateElementHydrateFile(parsedJayFile, RuntimeMode.MainTrusted)
903
- );
904
- await fs.mkdir(outputDir, { recursive: true });
905
- const tsPath = path__default.join(outputDir, "route.hydrate.ts");
906
- await fs.writeFile(tsPath, hydrateCode, "utf-8");
907
- const jayOptions = { tsConfigFilePath };
908
- await build({
909
- root: projectRoot,
910
- publicDir: false,
911
- plugins: [...jayStackCompiler(jayOptions)],
912
- build: {
913
- outDir: outputDir,
914
- emptyOutDir: false,
915
- minify,
916
- manifest: "route-hydrate-manifest.json",
917
- rollupOptions: {
918
- input: { "route.hydrate": tsPath },
919
- external: (id) => id.startsWith("@jay-framework/"),
920
- output: {
921
- entryFileNames: "[name]-[hash].js",
922
- format: "es"
923
- },
924
- preserveEntrySignatures: "exports-only"
925
- }
926
- },
927
- logLevel: "warn"
928
- });
929
- await fs.rm(tsPath, { force: true });
930
- const manifestPath = path__default.join(outputDir, "route-hydrate-manifest.json");
931
- const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8"));
932
- await fs.rm(manifestPath, { force: true });
933
- const entryKey = Object.keys(manifest).find((k) => manifest[k].isEntry);
934
- if (!entryKey) throw new Error("No entry in route hydrate manifest");
935
- const jsFile = manifest[entryKey].file;
936
- getLogger().info(`[Build] Compiled route hydrate script: ${jsFile}`);
937
- return { jsFile };
938
- }
939
- function resolveJayHtmlPaths(html, sourceDir, targetDir) {
940
- const root = parse(html, {
941
- comment: true,
942
- blockTextElements: { script: true, style: true }
943
- });
944
- const rewrite = (el, attr) => {
945
- const val = el.getAttribute(attr);
946
- if (val && (val.startsWith("./") || val.startsWith("../"))) {
947
- const abs = path__default.resolve(sourceDir, val);
948
- let rel = path__default.relative(targetDir, abs);
949
- if (!rel.startsWith(".")) rel = "./" + rel;
950
- el.setAttribute(attr, rel);
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
+ });
1106
+ }
1107
+ } catch {
1108
+ getLogger().warn(`[Build] Could not extract actions from ${sourcePath}`);
951
1109
  }
952
- };
953
- for (const el of root.querySelectorAll('script[type="application/jay-data"]')) {
954
- rewrite(el, "contract");
955
- }
956
- for (const el of root.querySelectorAll('script[type="application/jay-headless"]')) {
957
- rewrite(el, "src");
958
- rewrite(el, "contract");
959
- }
960
- for (const el of root.querySelectorAll('script[type="application/jay-headfull"]')) {
961
- rewrite(el, "src");
962
- rewrite(el, "contract");
963
- }
964
- for (const el of root.querySelectorAll('link[rel="stylesheet"]')) {
965
- rewrite(el, "href");
966
1110
  }
967
- return root.toString();
968
- }
969
- function extractCssImportUrls(css) {
970
- const imports = [];
971
- const re = /@import\s*(?:url\(\s*['"]?([^'")]+)['"]?\s*\)|['"]([^'"]+)['"])/g;
972
- let match;
973
- while ((match = re.exec(css)) !== null) {
974
- const url = match[1] || match[2];
975
- if (url.startsWith("https://")) {
976
- imports.push(url);
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
+ );
1131
+ }
977
1132
  }
1133
+ } catch (err) {
1134
+ getLogger().warn(`[Build] Plugin action scan failed: ${err.message}`);
978
1135
  }
979
- return imports;
980
- }
981
- async function generateRouteHydrationEntry(options) {
982
- const {
983
- hydrateImport,
984
- pageModulePath,
985
- pageExportName = "page",
986
- trackByMap,
987
- outputPath,
988
- keyedParts = [],
989
- clientInits = []
990
- } = options;
991
- const partImports = keyedParts.map((p, i) => `import { ${p.exportName} as keyedPart${i} } from '${p.modulePath}';`).join("\n");
992
- const hasPageModule = pageModulePath && pageExportName;
993
- const pagePartExpr = hasPageModule ? `pagePart && pagePart.comp ? { comp: pagePart.comp, contextMarkers: pagePart.contexts || [] } : null` : `null`;
994
- const partsArray = [
995
- pagePartExpr,
996
- ...keyedParts.map(
997
- (p, i) => `keyedPart${i} && keyedPart${i}.comp ? { comp: keyedPart${i}.comp, contextMarkers: keyedPart${i}.contexts || [], key: '${p.key}' } : null`
998
- )
999
- ];
1000
- const pageImport = hasPageModule ? `import { ${pageExportName} as pagePart } from '${pageModulePath}';` : "";
1001
- const initImports = clientInits.map((ci, i) => `import { ${ci.exportName} as clientInit${i} } from '${ci.modulePath}';`).join("\n");
1002
- const initCalls = clientInits.map(
1003
- (ci, i) => ` if (clientInit${i}?._clientInit) await clientInit${i}._clientInit(clientInitData['${ci.key}'] || {});`
1004
- ).join("\n");
1005
- const code = `import { hydrateCompositeJayComponent } from '@jay-framework/stack-client-runtime';
1006
- import { deepMergeViewStates } from '@jay-framework/view-state-merge';
1007
- import { hydrate } from '${hydrateImport}';
1008
- ${pageImport}
1009
- ${partImports}
1010
- ${initImports}
1011
-
1012
- const trackByMap = ${JSON.stringify(trackByMap)};
1013
-
1014
- export async function init(slowViewState, fastViewState, fastCarryForward, clientInitData) {
1015
- ${initCalls}
1016
- const viewState = deepMergeViewStates(slowViewState, fastViewState, trackByMap);
1017
- const target = document.getElementById('target');
1018
- const rootElement = target.firstElementChild;
1019
- const parts = [
1020
- ${partsArray.join(",\n ")}
1021
- ].filter(p => p !== null);
1022
- const pageComp = hydrateCompositeJayComponent(
1023
- hydrate, viewState, fastCarryForward,
1024
- parts, trackByMap, rootElement
1025
- );
1026
- return pageComp({});
1136
+ return { actions, plugins };
1027
1137
  }
1028
- `;
1029
- const outputDir = path__default.dirname(outputPath);
1030
- await fs.mkdir(outputDir, { recursive: true });
1031
- await fs.writeFile(outputPath, code, "utf-8");
1032
- getLogger().info(`[Build] Generated route hydration entry: ${path__default.basename(outputPath)}`);
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
+ );
1033
1144
  }
1034
- async function buildInstanceClient(hydrateEntryPath, instanceId, outputDir, projectRoot, jayOptions, minify = true, pagesRoot, buildDir) {
1145
+ createRequire(import.meta.url);
1146
+ async function scanPluginRoutes(projectRoot, projectRoutes) {
1035
1147
  const logger = getLogger();
1036
- await fs.mkdir(outputDir, { recursive: true });
1037
- const fullJayOptions = {
1038
- ...jayOptions,
1039
- ...pagesRoot && buildDir ? { pagesRoot, buildFolder: buildDir } : {}
1040
- };
1041
- await build({
1042
- root: projectRoot,
1043
- publicDir: false,
1044
- plugins: [...jayStackCompiler(fullJayOptions)],
1045
- build: {
1046
- outDir: outputDir,
1047
- emptyOutDir: false,
1048
- minify,
1049
- manifest: `${instanceId}-manifest.json`,
1050
- rollupOptions: {
1051
- input: { [instanceId]: hydrateEntryPath },
1052
- external: (id) => id.startsWith("@jay-framework/") || id === "jay-route-hydrate",
1053
- output: {
1054
- entryFileNames: "[name]-[hash].js",
1055
- chunkFileNames: "chunks/[name]-[hash].js",
1056
- assetFileNames: "[name]-[hash].[ext]",
1057
- format: "es"
1058
- },
1059
- preserveEntrySignatures: "exports-only"
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
+ );
1159
+ continue;
1060
1160
  }
1061
- },
1062
- logLevel: "warn"
1063
- });
1064
- const manifestPath = path__default.join(outputDir, `${instanceId}-manifest.json`);
1065
- const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8"));
1066
- await fs.rm(manifestPath, { force: true });
1067
- const entryKey = Object.keys(manifest).find((k) => manifest[k].isEntry);
1068
- if (!entryKey) {
1069
- throw new Error(`No entry found in instance build manifest for ${instanceId}`);
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;
1167
+ }
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
+ );
1182
+ }
1070
1183
  }
1071
- const entry = manifest[entryKey];
1072
- const result = {
1073
- jsFile: entry.file,
1074
- cssFile: entry.css?.[0]
1075
- };
1076
- logger.info(`[Build] Client bundle: ${result.jsFile}`);
1077
- return result;
1184
+ return pluginRoutes;
1185
+ }
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
+ }
1198
+ }
1199
+ } catch {
1200
+ }
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
+ }
1208
+ }
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");
1078
1224
  }
1079
1225
  async function discoverPluginClientPackages(projectRoot) {
1080
1226
  const projectRequire = createRequire(path__default.join(projectRoot, "package.json"));
@@ -1153,7 +1299,10 @@ async function buildVersion(options) {
1153
1299
  logger.important(`[Build] Project: ${options.projectRoot}`);
1154
1300
  await fs.mkdir(backendDir, { recursive: true });
1155
1301
  await fs.mkdir(frontendDir, { recursive: true });
1156
- const { entries, routes } = await discoverServerEntries(options.projectRoot, options.pagesRoot);
1302
+ const { entries, routes, errorPages } = await discoverServerEntries(
1303
+ options.projectRoot,
1304
+ options.pagesRoot
1305
+ );
1157
1306
  const serverOutputDir = path__default.join(backendDir, "server");
1158
1307
  await buildServerCode(
1159
1308
  entries,
@@ -1554,13 +1703,33 @@ async function buildVersion(options) {
1554
1703
  }
1555
1704
  }
1556
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);
1557
1724
  const manifest = {
1558
1725
  version: options.version,
1559
1726
  projectRoot: options.projectRoot,
1560
1727
  sharedManifest,
1561
1728
  routes: routeEntries.map((r) => r.entry),
1562
1729
  actions,
1563
- plugins
1730
+ plugins,
1731
+ ...siteHeadTags.length > 0 && { siteHeadTags },
1732
+ ...errorPageEntries && Object.keys(errorPageEntries).length > 0 && { errorPages: errorPageEntries }
1564
1733
  };
1565
1734
  await writeRouteManifest(manifest, backendDir);
1566
1735
  const publicFolder = path__default.join(options.projectRoot, "public");
@@ -1575,6 +1744,24 @@ async function buildVersion(options) {
1575
1744
  const urlCount = await generateSitemap(manifest, options.siteBaseUrl, sitemapPath);
1576
1745
  logger.important(`[Build] Sitemap generated: ${urlCount} URLs`);
1577
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
+ }
1578
1765
  const sourceHash = await computeBuildHash(buildDir);
1579
1766
  const metadata = {
1580
1767
  version: options.version,