qlara 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/aws.cjs CHANGED
@@ -599,7 +599,9 @@ var STACK_NAME_PREFIX = "qlara";
599
599
  var import_node_fs3 = require("fs");
600
600
  var import_node_path3 = require("path");
601
601
  var FALLBACK_FILENAME = "_fallback.html";
602
- var FALLBACK_PLACEHOLDER = "__QLARA_FALLBACK__";
602
+ function paramPlaceholder(paramName) {
603
+ return `__QLARA_FALLBACK_${paramName}__`;
604
+ }
603
605
  function generateFallbackFromTemplate(templateHtml, routePattern) {
604
606
  let fallback = templateHtml;
605
607
  const paramNames = (routePattern.match(/:([^/]+)/g) || []).map((m) => m.slice(1));
@@ -622,7 +624,7 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
622
624
  );
623
625
  fallback = fallback.replace(
624
626
  propsRegex,
625
- `{\\"${param}\\":\\"${FALLBACK_PLACEHOLDER}\\"}`
627
+ `{\\"${param}\\":\\"${paramPlaceholder(param)}\\"}`
626
628
  );
627
629
  }
628
630
  for (const param of paramNames) {
@@ -632,20 +634,30 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
632
634
  );
633
635
  fallback = fallback.replace(
634
636
  segmentRegex,
635
- `[\\"${param}\\",\\"${FALLBACK_PLACEHOLDER}\\",\\"d\\"]`
637
+ `[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
636
638
  );
637
639
  }
638
- const routeParts = routePattern.split("/").filter((p) => !p.startsWith(":"));
639
- if (routeParts.length > 0) {
640
- const prefix = routeParts.map((p) => `${q}${p}${q}`).join(",");
641
- const urlSegmentRegex = new RegExp(
642
- `(${q}c${q}:\\[${prefix},)${q}[^"]*${q}\\]`,
640
+ const allSegments = routePattern.split("/");
641
+ if (allSegments.length > 1) {
642
+ const regexParts = allSegments.map((seg) => {
643
+ if (seg.startsWith(":")) {
644
+ return `${q}[^"]*${q}`;
645
+ }
646
+ return `${q}${seg}${q}`;
647
+ });
648
+ const cArrayRegex = new RegExp(
649
+ `(${q}c${q}:\\[)${regexParts.join(",")}(\\])`,
643
650
  "g"
644
651
  );
645
- fallback = fallback.replace(
646
- urlSegmentRegex,
647
- `$1\\"${FALLBACK_PLACEHOLDER}\\"]`
648
- );
652
+ const replacementParts = allSegments.map((seg) => {
653
+ if (seg.startsWith(":")) {
654
+ const pName = seg.slice(1);
655
+ return `\\"${paramPlaceholder(pName)}\\"`;
656
+ }
657
+ return `\\"${seg}\\"`;
658
+ });
659
+ const replacement = `$1${replacementParts.join(",")}$2`;
660
+ fallback = fallback.replace(cArrayRegex, replacement);
649
661
  }
650
662
  fallback = fallback.replace(
651
663
  /8:\{\\"metadata\\":\[[\s\S]*?\],\\"error\\":null,\\"digest\\":\\"?\$undefined\\?"\}/,
package/dist/aws.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { P as ProviderResources, Q as QlaraProvider } from './types--KPPgCtc.cjs';
1
+ import { P as ProviderResources, Q as QlaraProvider } from './types-BmSR1R_Q.cjs';
2
2
 
3
3
  interface AwsConfig {
4
4
  stackName?: string;
package/dist/aws.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { P as ProviderResources, Q as QlaraProvider } from './types--KPPgCtc.js';
1
+ import { P as ProviderResources, Q as QlaraProvider } from './types-BmSR1R_Q.js';
2
2
 
3
3
  interface AwsConfig {
4
4
  stackName?: string;
package/dist/aws.js CHANGED
@@ -595,7 +595,9 @@ var STACK_NAME_PREFIX = "qlara";
595
595
  import { readFileSync as readFileSync3, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
596
596
  import { join as join3 } from "path";
597
597
  var FALLBACK_FILENAME = "_fallback.html";
598
- var FALLBACK_PLACEHOLDER = "__QLARA_FALLBACK__";
598
+ function paramPlaceholder(paramName) {
599
+ return `__QLARA_FALLBACK_${paramName}__`;
600
+ }
599
601
  function generateFallbackFromTemplate(templateHtml, routePattern) {
600
602
  let fallback = templateHtml;
601
603
  const paramNames = (routePattern.match(/:([^/]+)/g) || []).map((m) => m.slice(1));
@@ -618,7 +620,7 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
618
620
  );
619
621
  fallback = fallback.replace(
620
622
  propsRegex,
621
- `{\\"${param}\\":\\"${FALLBACK_PLACEHOLDER}\\"}`
623
+ `{\\"${param}\\":\\"${paramPlaceholder(param)}\\"}`
622
624
  );
623
625
  }
624
626
  for (const param of paramNames) {
@@ -628,20 +630,30 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
628
630
  );
629
631
  fallback = fallback.replace(
630
632
  segmentRegex,
631
- `[\\"${param}\\",\\"${FALLBACK_PLACEHOLDER}\\",\\"d\\"]`
633
+ `[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
632
634
  );
633
635
  }
634
- const routeParts = routePattern.split("/").filter((p) => !p.startsWith(":"));
635
- if (routeParts.length > 0) {
636
- const prefix = routeParts.map((p) => `${q}${p}${q}`).join(",");
637
- const urlSegmentRegex = new RegExp(
638
- `(${q}c${q}:\\[${prefix},)${q}[^"]*${q}\\]`,
636
+ const allSegments = routePattern.split("/");
637
+ if (allSegments.length > 1) {
638
+ const regexParts = allSegments.map((seg) => {
639
+ if (seg.startsWith(":")) {
640
+ return `${q}[^"]*${q}`;
641
+ }
642
+ return `${q}${seg}${q}`;
643
+ });
644
+ const cArrayRegex = new RegExp(
645
+ `(${q}c${q}:\\[)${regexParts.join(",")}(\\])`,
639
646
  "g"
640
647
  );
641
- fallback = fallback.replace(
642
- urlSegmentRegex,
643
- `$1\\"${FALLBACK_PLACEHOLDER}\\"]`
644
- );
648
+ const replacementParts = allSegments.map((seg) => {
649
+ if (seg.startsWith(":")) {
650
+ const pName = seg.slice(1);
651
+ return `\\"${paramPlaceholder(pName)}\\"`;
652
+ }
653
+ return `\\"${seg}\\"`;
654
+ });
655
+ const replacement = `$1${replacementParts.join(",")}$2`;
656
+ fallback = fallback.replace(cArrayRegex, replacement);
645
657
  }
646
658
  fallback = fallback.replace(
647
659
  /8:\{\\"metadata\\":\[[\s\S]*?\],\\"error\\":null,\\"digest\\":\\"?\$undefined\\?"\}/,
package/dist/cli.js CHANGED
@@ -603,7 +603,9 @@ async function bundleRenderer(routeFile, cacheTtl = 3600, framework) {
603
603
  import { readFileSync as readFileSync3, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
604
604
  import { join as join3 } from "path";
605
605
  var FALLBACK_FILENAME = "_fallback.html";
606
- var FALLBACK_PLACEHOLDER = "__QLARA_FALLBACK__";
606
+ function paramPlaceholder(paramName) {
607
+ return `__QLARA_FALLBACK_${paramName}__`;
608
+ }
607
609
  function generateFallbackFromTemplate(templateHtml, routePattern) {
608
610
  let fallback = templateHtml;
609
611
  const paramNames = (routePattern.match(/:([^/]+)/g) || []).map((m) => m.slice(1));
@@ -626,7 +628,7 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
626
628
  );
627
629
  fallback = fallback.replace(
628
630
  propsRegex,
629
- `{\\"${param}\\":\\"${FALLBACK_PLACEHOLDER}\\"}`
631
+ `{\\"${param}\\":\\"${paramPlaceholder(param)}\\"}`
630
632
  );
631
633
  }
632
634
  for (const param of paramNames) {
@@ -636,20 +638,30 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
636
638
  );
637
639
  fallback = fallback.replace(
638
640
  segmentRegex,
639
- `[\\"${param}\\",\\"${FALLBACK_PLACEHOLDER}\\",\\"d\\"]`
641
+ `[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
640
642
  );
641
643
  }
642
- const routeParts = routePattern.split("/").filter((p) => !p.startsWith(":"));
643
- if (routeParts.length > 0) {
644
- const prefix = routeParts.map((p) => `${q}${p}${q}`).join(",");
645
- const urlSegmentRegex = new RegExp(
646
- `(${q}c${q}:\\[${prefix},)${q}[^"]*${q}\\]`,
644
+ const allSegments = routePattern.split("/");
645
+ if (allSegments.length > 1) {
646
+ const regexParts = allSegments.map((seg) => {
647
+ if (seg.startsWith(":")) {
648
+ return `${q}[^"]*${q}`;
649
+ }
650
+ return `${q}${seg}${q}`;
651
+ });
652
+ const cArrayRegex = new RegExp(
653
+ `(${q}c${q}:\\[)${regexParts.join(",")}(\\])`,
647
654
  "g"
648
655
  );
649
- fallback = fallback.replace(
650
- urlSegmentRegex,
651
- `$1\\"${FALLBACK_PLACEHOLDER}\\"]`
652
- );
656
+ const replacementParts = allSegments.map((seg) => {
657
+ if (seg.startsWith(":")) {
658
+ const pName = seg.slice(1);
659
+ return `\\"${paramPlaceholder(pName)}\\"`;
660
+ }
661
+ return `\\"${seg}\\"`;
662
+ });
663
+ const replacement = `$1${replacementParts.join(",")}$2`;
664
+ fallback = fallback.replace(cArrayRegex, replacement);
653
665
  }
654
666
  fallback = fallback.replace(
655
667
  /8:\{\\"metadata\\":\[[\s\S]*?\],\\"error\\":null,\\"digest\\":\\"?\$undefined\\?"\}/,
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as QlaraPluginConfig, b as QlaraRoute, c as QlaraManifest, M as ManifestRoute, R as RouteMatch } from './types--KPPgCtc.cjs';
2
- export { P as ProviderResources, d as QlaraAlternateLinkDescriptor, e as QlaraAlternateURLs, f as QlaraAppLinks, g as QlaraAppLinksAndroid, h as QlaraAppLinksApple, i as QlaraAppLinksWeb, j as QlaraAppLinksWindows, k as QlaraAppleImage, l as QlaraAppleImageDescriptor, m as QlaraAppleWebApp, n as QlaraAuthor, o as QlaraDeployConfig, p as QlaraFacebook, q as QlaraFormatDetection, r as QlaraIcon, s as QlaraIconDescriptor, t as QlaraIcons, u as QlaraItunesApp, v as QlaraMetaDataGenerator, w as QlaraMetadata, x as QlaraOGAudio, y as QlaraOGAudioDescriptor, z as QlaraOGImage, A as QlaraOGImageDescriptor, B as QlaraOGVideo, C as QlaraOGVideoDescriptor, D as QlaraOpenGraph, E as QlaraOpenGraphArticle, F as QlaraOpenGraphBase, G as QlaraOpenGraphBook, H as QlaraOpenGraphMusicAlbum, I as QlaraOpenGraphMusicPlaylist, J as QlaraOpenGraphMusicRadioStation, K as QlaraOpenGraphMusicSong, L as QlaraOpenGraphProfile, N as QlaraOpenGraphVideoEpisode, O as QlaraOpenGraphVideoMovie, S as QlaraOpenGraphVideoOther, T as QlaraOpenGraphVideoTVShow, U as QlaraOpenGraphWebsite, V as QlaraPinterest, Q as QlaraProvider, W as QlaraReferrer, X as QlaraRobots, Y as QlaraRobotsInfo, Z as QlaraRouteDefinition, _ as QlaraRoutes, $ as QlaraTwitter, a0 as QlaraTwitterApp, a1 as QlaraTwitterAppDescriptor, a2 as QlaraTwitterBase, a3 as QlaraTwitterImage, a4 as QlaraTwitterImageDescriptor, a5 as QlaraTwitterPlayer, a6 as QlaraTwitterPlayerDescriptor, a7 as QlaraTwitterSummary, a8 as QlaraTwitterSummaryLargeImage, a9 as QlaraVerification } from './types--KPPgCtc.cjs';
1
+ import { a as QlaraPluginConfig, b as QlaraRoute, c as QlaraManifest, M as ManifestRoute, R as RouteMatch } from './types-BmSR1R_Q.cjs';
2
+ export { P as ProviderResources, d as QlaraAlternateLinkDescriptor, e as QlaraAlternateURLs, f as QlaraAppLinks, g as QlaraAppLinksAndroid, h as QlaraAppLinksApple, i as QlaraAppLinksWeb, j as QlaraAppLinksWindows, k as QlaraAppleImage, l as QlaraAppleImageDescriptor, m as QlaraAppleWebApp, n as QlaraAuthor, o as QlaraDeployConfig, p as QlaraFacebook, q as QlaraFormatDetection, r as QlaraIcon, s as QlaraIconDescriptor, t as QlaraIcons, u as QlaraItunesApp, v as QlaraMetaDataGenerator, w as QlaraMetadata, x as QlaraOGAudio, y as QlaraOGAudioDescriptor, z as QlaraOGImage, A as QlaraOGImageDescriptor, B as QlaraOGVideo, C as QlaraOGVideoDescriptor, D as QlaraOpenGraph, E as QlaraOpenGraphArticle, F as QlaraOpenGraphBase, G as QlaraOpenGraphBook, H as QlaraOpenGraphMusicAlbum, I as QlaraOpenGraphMusicPlaylist, J as QlaraOpenGraphMusicRadioStation, K as QlaraOpenGraphMusicSong, L as QlaraOpenGraphProfile, N as QlaraOpenGraphVideoEpisode, O as QlaraOpenGraphVideoMovie, S as QlaraOpenGraphVideoOther, T as QlaraOpenGraphVideoTVShow, U as QlaraOpenGraphWebsite, V as QlaraPinterest, Q as QlaraProvider, W as QlaraReferrer, X as QlaraRobots, Y as QlaraRobotsInfo, Z as QlaraRouteDefinition, _ as QlaraRoutes, $ as QlaraTwitter, a0 as QlaraTwitterApp, a1 as QlaraTwitterAppDescriptor, a2 as QlaraTwitterBase, a3 as QlaraTwitterImage, a4 as QlaraTwitterImageDescriptor, a5 as QlaraTwitterPlayer, a6 as QlaraTwitterPlayerDescriptor, a7 as QlaraTwitterSummary, a8 as QlaraTwitterSummaryLargeImage, a9 as QlaraValidate, aa as QlaraVerification } from './types-BmSR1R_Q.cjs';
3
3
 
4
4
  declare function validateConfig(config: QlaraPluginConfig, routes: QlaraRoute[]): void;
5
5
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as QlaraPluginConfig, b as QlaraRoute, c as QlaraManifest, M as ManifestRoute, R as RouteMatch } from './types--KPPgCtc.js';
2
- export { P as ProviderResources, d as QlaraAlternateLinkDescriptor, e as QlaraAlternateURLs, f as QlaraAppLinks, g as QlaraAppLinksAndroid, h as QlaraAppLinksApple, i as QlaraAppLinksWeb, j as QlaraAppLinksWindows, k as QlaraAppleImage, l as QlaraAppleImageDescriptor, m as QlaraAppleWebApp, n as QlaraAuthor, o as QlaraDeployConfig, p as QlaraFacebook, q as QlaraFormatDetection, r as QlaraIcon, s as QlaraIconDescriptor, t as QlaraIcons, u as QlaraItunesApp, v as QlaraMetaDataGenerator, w as QlaraMetadata, x as QlaraOGAudio, y as QlaraOGAudioDescriptor, z as QlaraOGImage, A as QlaraOGImageDescriptor, B as QlaraOGVideo, C as QlaraOGVideoDescriptor, D as QlaraOpenGraph, E as QlaraOpenGraphArticle, F as QlaraOpenGraphBase, G as QlaraOpenGraphBook, H as QlaraOpenGraphMusicAlbum, I as QlaraOpenGraphMusicPlaylist, J as QlaraOpenGraphMusicRadioStation, K as QlaraOpenGraphMusicSong, L as QlaraOpenGraphProfile, N as QlaraOpenGraphVideoEpisode, O as QlaraOpenGraphVideoMovie, S as QlaraOpenGraphVideoOther, T as QlaraOpenGraphVideoTVShow, U as QlaraOpenGraphWebsite, V as QlaraPinterest, Q as QlaraProvider, W as QlaraReferrer, X as QlaraRobots, Y as QlaraRobotsInfo, Z as QlaraRouteDefinition, _ as QlaraRoutes, $ as QlaraTwitter, a0 as QlaraTwitterApp, a1 as QlaraTwitterAppDescriptor, a2 as QlaraTwitterBase, a3 as QlaraTwitterImage, a4 as QlaraTwitterImageDescriptor, a5 as QlaraTwitterPlayer, a6 as QlaraTwitterPlayerDescriptor, a7 as QlaraTwitterSummary, a8 as QlaraTwitterSummaryLargeImage, a9 as QlaraVerification } from './types--KPPgCtc.js';
1
+ import { a as QlaraPluginConfig, b as QlaraRoute, c as QlaraManifest, M as ManifestRoute, R as RouteMatch } from './types-BmSR1R_Q.js';
2
+ export { P as ProviderResources, d as QlaraAlternateLinkDescriptor, e as QlaraAlternateURLs, f as QlaraAppLinks, g as QlaraAppLinksAndroid, h as QlaraAppLinksApple, i as QlaraAppLinksWeb, j as QlaraAppLinksWindows, k as QlaraAppleImage, l as QlaraAppleImageDescriptor, m as QlaraAppleWebApp, n as QlaraAuthor, o as QlaraDeployConfig, p as QlaraFacebook, q as QlaraFormatDetection, r as QlaraIcon, s as QlaraIconDescriptor, t as QlaraIcons, u as QlaraItunesApp, v as QlaraMetaDataGenerator, w as QlaraMetadata, x as QlaraOGAudio, y as QlaraOGAudioDescriptor, z as QlaraOGImage, A as QlaraOGImageDescriptor, B as QlaraOGVideo, C as QlaraOGVideoDescriptor, D as QlaraOpenGraph, E as QlaraOpenGraphArticle, F as QlaraOpenGraphBase, G as QlaraOpenGraphBook, H as QlaraOpenGraphMusicAlbum, I as QlaraOpenGraphMusicPlaylist, J as QlaraOpenGraphMusicRadioStation, K as QlaraOpenGraphMusicSong, L as QlaraOpenGraphProfile, N as QlaraOpenGraphVideoEpisode, O as QlaraOpenGraphVideoMovie, S as QlaraOpenGraphVideoOther, T as QlaraOpenGraphVideoTVShow, U as QlaraOpenGraphWebsite, V as QlaraPinterest, Q as QlaraProvider, W as QlaraReferrer, X as QlaraRobots, Y as QlaraRobotsInfo, Z as QlaraRouteDefinition, _ as QlaraRoutes, $ as QlaraTwitter, a0 as QlaraTwitterApp, a1 as QlaraTwitterAppDescriptor, a2 as QlaraTwitterBase, a3 as QlaraTwitterImage, a4 as QlaraTwitterImageDescriptor, a5 as QlaraTwitterPlayer, a6 as QlaraTwitterPlayerDescriptor, a7 as QlaraTwitterSummary, a8 as QlaraTwitterSummaryLargeImage, a9 as QlaraValidate, aa as QlaraVerification } from './types-BmSR1R_Q.js';
3
3
 
4
4
  declare function validateConfig(config: QlaraPluginConfig, routes: QlaraRoute[]): void;
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { NextConfig } from 'next';
2
- import { a as QlaraPluginConfig } from '../types--KPPgCtc.cjs';
2
+ import { a as QlaraPluginConfig } from '../types-BmSR1R_Q.cjs';
3
3
 
4
4
  /**
5
5
  * Wrap a Next.js config with Qlara.
@@ -1,5 +1,5 @@
1
1
  import { NextConfig } from 'next';
2
- import { a as QlaraPluginConfig } from '../types--KPPgCtc.js';
2
+ import { a as QlaraPluginConfig } from '../types-BmSR1R_Q.js';
3
3
 
4
4
  /**
5
5
  * Wrap a Next.js config with Qlara.
@@ -315,31 +315,60 @@ interface QlaraMetadata {
315
315
  other?: Record<string, string | number | (string | number)[]>;
316
316
  }
317
317
  /**
318
- * Function that fetches data for a dynamic route and returns metadata.
318
+ * Function that generates metadata for a dynamic route.
319
319
  * Equivalent to Next.js `generateMetadata()` — runs in the renderer Lambda
320
320
  * with access to the data source.
321
321
  *
322
- * @param params - The route parameters, e.g. { id: '42' } for /product/:id
322
+ * @param params - All route parameters, e.g. { lang: 'en', id: '42' } for /:lang/products/:id
323
323
  * @returns Metadata for the page, or null if the page doesn't exist
324
324
  */
325
325
  type QlaraMetaDataGenerator = (params: Record<string, string>) => Promise<QlaraMetadata | null>;
326
- /** A single route definition with its pattern and metadata generator. */
326
+ /**
327
+ * Optional validation function for a dynamic route.
328
+ * Called before reading the fallback or generating metadata.
329
+ * Use this to cheaply reject invalid param combinations (e.g., unsupported languages).
330
+ *
331
+ * Receives ALL route parameters — you can validate every dynamic segment.
332
+ *
333
+ * @param params - All route parameters, e.g. { lang: 'en', id: '42' }
334
+ * @returns true if the params are valid, false to return 404 immediately
335
+ */
336
+ type QlaraValidate = (params: Record<string, string>) => Promise<boolean>;
337
+ /**
338
+ * A single route definition at the leaf page level.
339
+ *
340
+ * Follows the Next.js "generate from bottom up" pattern:
341
+ * define one route entry per leaf page with ALL dynamic params.
342
+ */
327
343
  interface QlaraRouteDefinition {
328
- /** Dynamic route pattern, e.g. '/product/:id' */
344
+ /** Dynamic route pattern, e.g. '/:lang/products/:id' */
329
345
  route: string;
330
- /** Function that fetches metadata for this route from the data source */
331
- metaDataGenerator: QlaraMetaDataGenerator;
346
+ /**
347
+ * Optional validation function — called before rendering.
348
+ * Return `false` to 404 immediately without generating the page.
349
+ * Receives all params so you can validate every dynamic segment.
350
+ */
351
+ validate?: QlaraValidate;
352
+ /**
353
+ * Function that generates metadata for this route.
354
+ * Preferred name — use this instead of `metaDataGenerator`.
355
+ */
356
+ generateMetadata?: QlaraMetaDataGenerator;
357
+ /**
358
+ * @deprecated Use `generateMetadata` instead. Kept for backward compatibility.
359
+ */
360
+ metaDataGenerator?: QlaraMetaDataGenerator;
332
361
  }
333
362
  /**
334
363
  * The route file default export type: an array of route definitions.
335
364
  *
336
- * Example:
365
+ * Example (single param):
337
366
  * ```typescript
338
367
  * import type { QlaraRoutes } from 'qlara';
339
368
  * const routes: QlaraRoutes = [
340
369
  * {
341
370
  * route: '/product/:id',
342
- * metaDataGenerator: async (params) => {
371
+ * generateMetadata: async (params) => {
343
372
  * const product = await getProduct(params.id);
344
373
  * if (!product) return null;
345
374
  * return { title: product.name, description: product.description };
@@ -348,6 +377,25 @@ interface QlaraRouteDefinition {
348
377
  * ];
349
378
  * export default routes;
350
379
  * ```
380
+ *
381
+ * Example (multiple params with validation):
382
+ * ```typescript
383
+ * const routes: QlaraRoutes = [
384
+ * {
385
+ * route: '/:lang/products/:id',
386
+ * validate: async (params) => {
387
+ * if (!['en', 'da', 'de'].includes(params.lang)) return false;
388
+ * const product = await getProduct(params.id);
389
+ * return !!product;
390
+ * },
391
+ * generateMetadata: async (params) => {
392
+ * const product = await getProduct(params.id);
393
+ * if (!product) return null;
394
+ * return { title: `${product.name} | Store` };
395
+ * },
396
+ * },
397
+ * ];
398
+ * ```
351
399
  */
352
400
  type QlaraRoutes = QlaraRouteDefinition[];
353
401
  interface QlaraRoute {
@@ -420,4 +468,4 @@ interface RouteMatch {
420
468
  params: Record<string, string>;
421
469
  }
422
470
 
423
- export type { QlaraTwitter as $, QlaraOGImageDescriptor as A, QlaraOGVideo as B, QlaraOGVideoDescriptor as C, QlaraOpenGraph as D, QlaraOpenGraphArticle as E, QlaraOpenGraphBase as F, QlaraOpenGraphBook as G, QlaraOpenGraphMusicAlbum as H, QlaraOpenGraphMusicPlaylist as I, QlaraOpenGraphMusicRadioStation as J, QlaraOpenGraphMusicSong as K, QlaraOpenGraphProfile as L, ManifestRoute as M, QlaraOpenGraphVideoEpisode as N, QlaraOpenGraphVideoMovie as O, ProviderResources as P, QlaraProvider as Q, RouteMatch as R, QlaraOpenGraphVideoOther as S, QlaraOpenGraphVideoTVShow as T, QlaraOpenGraphWebsite as U, QlaraPinterest as V, QlaraReferrer as W, QlaraRobots as X, QlaraRobotsInfo as Y, QlaraRouteDefinition as Z, QlaraRoutes as _, QlaraPluginConfig as a, QlaraTwitterApp as a0, QlaraTwitterAppDescriptor as a1, QlaraTwitterBase as a2, QlaraTwitterImage as a3, QlaraTwitterImageDescriptor as a4, QlaraTwitterPlayer as a5, QlaraTwitterPlayerDescriptor as a6, QlaraTwitterSummary as a7, QlaraTwitterSummaryLargeImage as a8, QlaraVerification as a9, QlaraRoute as b, QlaraManifest as c, QlaraAlternateLinkDescriptor as d, QlaraAlternateURLs as e, QlaraAppLinks as f, QlaraAppLinksAndroid as g, QlaraAppLinksApple as h, QlaraAppLinksWeb as i, QlaraAppLinksWindows as j, QlaraAppleImage as k, QlaraAppleImageDescriptor as l, QlaraAppleWebApp as m, QlaraAuthor as n, QlaraDeployConfig as o, QlaraFacebook as p, QlaraFormatDetection as q, QlaraIcon as r, QlaraIconDescriptor as s, QlaraIcons as t, QlaraItunesApp as u, QlaraMetaDataGenerator as v, QlaraMetadata as w, QlaraOGAudio as x, QlaraOGAudioDescriptor as y, QlaraOGImage as z };
471
+ export type { QlaraTwitter as $, QlaraOGImageDescriptor as A, QlaraOGVideo as B, QlaraOGVideoDescriptor as C, QlaraOpenGraph as D, QlaraOpenGraphArticle as E, QlaraOpenGraphBase as F, QlaraOpenGraphBook as G, QlaraOpenGraphMusicAlbum as H, QlaraOpenGraphMusicPlaylist as I, QlaraOpenGraphMusicRadioStation as J, QlaraOpenGraphMusicSong as K, QlaraOpenGraphProfile as L, ManifestRoute as M, QlaraOpenGraphVideoEpisode as N, QlaraOpenGraphVideoMovie as O, ProviderResources as P, QlaraProvider as Q, RouteMatch as R, QlaraOpenGraphVideoOther as S, QlaraOpenGraphVideoTVShow as T, QlaraOpenGraphWebsite as U, QlaraPinterest as V, QlaraReferrer as W, QlaraRobots as X, QlaraRobotsInfo as Y, QlaraRouteDefinition as Z, QlaraRoutes as _, QlaraPluginConfig as a, QlaraTwitterApp as a0, QlaraTwitterAppDescriptor as a1, QlaraTwitterBase as a2, QlaraTwitterImage as a3, QlaraTwitterImageDescriptor as a4, QlaraTwitterPlayer as a5, QlaraTwitterPlayerDescriptor as a6, QlaraTwitterSummary as a7, QlaraTwitterSummaryLargeImage as a8, QlaraValidate as a9, QlaraVerification as aa, QlaraRoute as b, QlaraManifest as c, QlaraAlternateLinkDescriptor as d, QlaraAlternateURLs as e, QlaraAppLinks as f, QlaraAppLinksAndroid as g, QlaraAppLinksApple as h, QlaraAppLinksWeb as i, QlaraAppLinksWindows as j, QlaraAppleImage as k, QlaraAppleImageDescriptor as l, QlaraAppleWebApp as m, QlaraAuthor as n, QlaraDeployConfig as o, QlaraFacebook as p, QlaraFormatDetection as q, QlaraIcon as r, QlaraIconDescriptor as s, QlaraIcons as t, QlaraItunesApp as u, QlaraMetaDataGenerator as v, QlaraMetadata as w, QlaraOGAudio as x, QlaraOGAudioDescriptor as y, QlaraOGImage as z };
@@ -315,31 +315,60 @@ interface QlaraMetadata {
315
315
  other?: Record<string, string | number | (string | number)[]>;
316
316
  }
317
317
  /**
318
- * Function that fetches data for a dynamic route and returns metadata.
318
+ * Function that generates metadata for a dynamic route.
319
319
  * Equivalent to Next.js `generateMetadata()` — runs in the renderer Lambda
320
320
  * with access to the data source.
321
321
  *
322
- * @param params - The route parameters, e.g. { id: '42' } for /product/:id
322
+ * @param params - All route parameters, e.g. { lang: 'en', id: '42' } for /:lang/products/:id
323
323
  * @returns Metadata for the page, or null if the page doesn't exist
324
324
  */
325
325
  type QlaraMetaDataGenerator = (params: Record<string, string>) => Promise<QlaraMetadata | null>;
326
- /** A single route definition with its pattern and metadata generator. */
326
+ /**
327
+ * Optional validation function for a dynamic route.
328
+ * Called before reading the fallback or generating metadata.
329
+ * Use this to cheaply reject invalid param combinations (e.g., unsupported languages).
330
+ *
331
+ * Receives ALL route parameters — you can validate every dynamic segment.
332
+ *
333
+ * @param params - All route parameters, e.g. { lang: 'en', id: '42' }
334
+ * @returns true if the params are valid, false to return 404 immediately
335
+ */
336
+ type QlaraValidate = (params: Record<string, string>) => Promise<boolean>;
337
+ /**
338
+ * A single route definition at the leaf page level.
339
+ *
340
+ * Follows the Next.js "generate from bottom up" pattern:
341
+ * define one route entry per leaf page with ALL dynamic params.
342
+ */
327
343
  interface QlaraRouteDefinition {
328
- /** Dynamic route pattern, e.g. '/product/:id' */
344
+ /** Dynamic route pattern, e.g. '/:lang/products/:id' */
329
345
  route: string;
330
- /** Function that fetches metadata for this route from the data source */
331
- metaDataGenerator: QlaraMetaDataGenerator;
346
+ /**
347
+ * Optional validation function — called before rendering.
348
+ * Return `false` to 404 immediately without generating the page.
349
+ * Receives all params so you can validate every dynamic segment.
350
+ */
351
+ validate?: QlaraValidate;
352
+ /**
353
+ * Function that generates metadata for this route.
354
+ * Preferred name — use this instead of `metaDataGenerator`.
355
+ */
356
+ generateMetadata?: QlaraMetaDataGenerator;
357
+ /**
358
+ * @deprecated Use `generateMetadata` instead. Kept for backward compatibility.
359
+ */
360
+ metaDataGenerator?: QlaraMetaDataGenerator;
332
361
  }
333
362
  /**
334
363
  * The route file default export type: an array of route definitions.
335
364
  *
336
- * Example:
365
+ * Example (single param):
337
366
  * ```typescript
338
367
  * import type { QlaraRoutes } from 'qlara';
339
368
  * const routes: QlaraRoutes = [
340
369
  * {
341
370
  * route: '/product/:id',
342
- * metaDataGenerator: async (params) => {
371
+ * generateMetadata: async (params) => {
343
372
  * const product = await getProduct(params.id);
344
373
  * if (!product) return null;
345
374
  * return { title: product.name, description: product.description };
@@ -348,6 +377,25 @@ interface QlaraRouteDefinition {
348
377
  * ];
349
378
  * export default routes;
350
379
  * ```
380
+ *
381
+ * Example (multiple params with validation):
382
+ * ```typescript
383
+ * const routes: QlaraRoutes = [
384
+ * {
385
+ * route: '/:lang/products/:id',
386
+ * validate: async (params) => {
387
+ * if (!['en', 'da', 'de'].includes(params.lang)) return false;
388
+ * const product = await getProduct(params.id);
389
+ * return !!product;
390
+ * },
391
+ * generateMetadata: async (params) => {
392
+ * const product = await getProduct(params.id);
393
+ * if (!product) return null;
394
+ * return { title: `${product.name} | Store` };
395
+ * },
396
+ * },
397
+ * ];
398
+ * ```
351
399
  */
352
400
  type QlaraRoutes = QlaraRouteDefinition[];
353
401
  interface QlaraRoute {
@@ -420,4 +468,4 @@ interface RouteMatch {
420
468
  params: Record<string, string>;
421
469
  }
422
470
 
423
- export type { QlaraTwitter as $, QlaraOGImageDescriptor as A, QlaraOGVideo as B, QlaraOGVideoDescriptor as C, QlaraOpenGraph as D, QlaraOpenGraphArticle as E, QlaraOpenGraphBase as F, QlaraOpenGraphBook as G, QlaraOpenGraphMusicAlbum as H, QlaraOpenGraphMusicPlaylist as I, QlaraOpenGraphMusicRadioStation as J, QlaraOpenGraphMusicSong as K, QlaraOpenGraphProfile as L, ManifestRoute as M, QlaraOpenGraphVideoEpisode as N, QlaraOpenGraphVideoMovie as O, ProviderResources as P, QlaraProvider as Q, RouteMatch as R, QlaraOpenGraphVideoOther as S, QlaraOpenGraphVideoTVShow as T, QlaraOpenGraphWebsite as U, QlaraPinterest as V, QlaraReferrer as W, QlaraRobots as X, QlaraRobotsInfo as Y, QlaraRouteDefinition as Z, QlaraRoutes as _, QlaraPluginConfig as a, QlaraTwitterApp as a0, QlaraTwitterAppDescriptor as a1, QlaraTwitterBase as a2, QlaraTwitterImage as a3, QlaraTwitterImageDescriptor as a4, QlaraTwitterPlayer as a5, QlaraTwitterPlayerDescriptor as a6, QlaraTwitterSummary as a7, QlaraTwitterSummaryLargeImage as a8, QlaraVerification as a9, QlaraRoute as b, QlaraManifest as c, QlaraAlternateLinkDescriptor as d, QlaraAlternateURLs as e, QlaraAppLinks as f, QlaraAppLinksAndroid as g, QlaraAppLinksApple as h, QlaraAppLinksWeb as i, QlaraAppLinksWindows as j, QlaraAppleImage as k, QlaraAppleImageDescriptor as l, QlaraAppleWebApp as m, QlaraAuthor as n, QlaraDeployConfig as o, QlaraFacebook as p, QlaraFormatDetection as q, QlaraIcon as r, QlaraIconDescriptor as s, QlaraIcons as t, QlaraItunesApp as u, QlaraMetaDataGenerator as v, QlaraMetadata as w, QlaraOGAudio as x, QlaraOGAudioDescriptor as y, QlaraOGImage as z };
471
+ export type { QlaraTwitter as $, QlaraOGImageDescriptor as A, QlaraOGVideo as B, QlaraOGVideoDescriptor as C, QlaraOpenGraph as D, QlaraOpenGraphArticle as E, QlaraOpenGraphBase as F, QlaraOpenGraphBook as G, QlaraOpenGraphMusicAlbum as H, QlaraOpenGraphMusicPlaylist as I, QlaraOpenGraphMusicRadioStation as J, QlaraOpenGraphMusicSong as K, QlaraOpenGraphProfile as L, ManifestRoute as M, QlaraOpenGraphVideoEpisode as N, QlaraOpenGraphVideoMovie as O, ProviderResources as P, QlaraProvider as Q, RouteMatch as R, QlaraOpenGraphVideoOther as S, QlaraOpenGraphVideoTVShow as T, QlaraOpenGraphWebsite as U, QlaraPinterest as V, QlaraReferrer as W, QlaraRobots as X, QlaraRobotsInfo as Y, QlaraRouteDefinition as Z, QlaraRoutes as _, QlaraPluginConfig as a, QlaraTwitterApp as a0, QlaraTwitterAppDescriptor as a1, QlaraTwitterBase as a2, QlaraTwitterImage as a3, QlaraTwitterImageDescriptor as a4, QlaraTwitterPlayer as a5, QlaraTwitterPlayerDescriptor as a6, QlaraTwitterSummary as a7, QlaraTwitterSummaryLargeImage as a8, QlaraValidate as a9, QlaraVerification as aa, QlaraRoute as b, QlaraManifest as c, QlaraAlternateLinkDescriptor as d, QlaraAlternateURLs as e, QlaraAppLinks as f, QlaraAppLinksAndroid as g, QlaraAppLinksApple as h, QlaraAppLinksWeb as i, QlaraAppLinksWindows as j, QlaraAppleImage as k, QlaraAppleImageDescriptor as l, QlaraAppleWebApp as m, QlaraAuthor as n, QlaraDeployConfig as o, QlaraFacebook as p, QlaraFormatDetection as q, QlaraIcon as r, QlaraIconDescriptor as s, QlaraIcons as t, QlaraItunesApp as u, QlaraMetaDataGenerator as v, QlaraMetadata as w, QlaraOGAudio as x, QlaraOGAudioDescriptor as y, QlaraOGImage as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qlara",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Runtime ISR for static React apps — dynamic routing and SEO metadata for statically exported Next.js apps on AWS",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -15,7 +15,7 @@
15
15
  * route definitions, each with a pattern and a metaDataGenerator function.
16
16
  */
17
17
 
18
- import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
18
+ import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
19
19
  import type {
20
20
  QlaraMetadata,
21
21
  QlaraOpenGraph,
@@ -78,8 +78,6 @@ interface RendererResult {
78
78
  html?: string;
79
79
  }
80
80
 
81
- const FALLBACK_PLACEHOLDER = '__QLARA_FALLBACK__';
82
-
83
81
  // Module-scope S3 client — reused across warm invocations (avoids recreating TCP/TLS connections)
84
82
  const s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });
85
83
 
@@ -95,23 +93,27 @@ function deriveS3Key(uri: string): string {
95
93
  }
96
94
 
97
95
  /**
98
- * Derive the fallback S3 key from a URI.
99
- * /product/42 product/_fallback.html
96
+ * Derive the fallback S3 key from a route pattern.
97
+ * Filters out dynamic segments (starting with ':') and appends _fallback.html.
98
+ * Must match getFallbackKey() in fallback.ts.
99
+ *
100
+ * '/product/:id' → 'product/_fallback.html'
101
+ * '/:lang/products/:id' → 'products/_fallback.html'
102
+ * '/:a/:b/:c' → '_fallback.html'
100
103
  */
101
- function deriveFallbackKey(uri: string): string {
102
- const cleanUri = uri.replace(/^\//, '').replace(/\/$/, '');
103
- const parts = cleanUri.split('/');
104
- if (parts.length < 2) return '_fallback.html';
105
- return parts.slice(0, -1).join('/') + '/_fallback.html';
104
+ function deriveFallbackKey(routePattern: string): string {
105
+ const parts = routePattern.replace(/^\//, '').split('/');
106
+ const dirParts = parts.filter(p => !p.startsWith(':'));
107
+ return [...dirParts, '_fallback.html'].join('/');
106
108
  }
107
109
 
108
110
  /**
109
- * Extract the last path segment (the dynamic param value) from a URI.
110
- * /product/42 '42'
111
+ * Generate a per-param placeholder string.
112
+ * Must match paramPlaceholder() in fallback.ts (inlined because renderer
113
+ * is bundled as a standalone Lambda ZIP).
111
114
  */
112
- function extractParamValue(uri: string): string {
113
- const cleanUri = uri.replace(/^\//, '').replace(/\/$/, '');
114
- return cleanUri.split('/').pop() || '';
115
+ function paramPlaceholder(paramName: string): string {
116
+ return `__QLARA_FALLBACK_${paramName}__`;
115
117
  }
116
118
 
117
119
  // ── Helpers: normalize single-or-array values ────────────────────
@@ -788,6 +790,322 @@ function extractRscFlightData(html: string): string | null {
788
790
  return chunks.join('');
789
791
  }
790
792
 
793
+ // ── Per-segment prefetch file generation (Next.js 16+) ───────────
794
+ //
795
+ // Next.js 16 introduced per-segment prefetch files in a subdirectory
796
+ // per page (e.g. product/1/__next._tree.txt). The renderer must generate
797
+ // these for renderer-created pages so they match build-time pages.
798
+ //
799
+ // Approach: discover segment files from a build-time reference page on S3,
800
+ // classify each file, and copy/patch as needed. If no reference page exists
801
+ // (Next.js 15 or no build-time pages), segment generation is skipped.
802
+
803
+ /** Cache reference segment directory per route prefix across warm invocations */
804
+ const referencePageCache = new Map<string, string | null>();
805
+
806
+ /**
807
+ * Find a build-time page's segment directory in S3 to use as a template.
808
+ * Returns the S3 key prefix (e.g. 'product/1/') or null.
809
+ */
810
+ async function findReferenceSegmentDir(bucket: string, routePrefix: string): Promise<string | null> {
811
+ const cached = referencePageCache.get(routePrefix);
812
+ if (cached !== undefined) return cached;
813
+
814
+ try {
815
+ const response = await s3.send(new ListObjectsV2Command({
816
+ Bucket: bucket,
817
+ Prefix: `${routePrefix}/`,
818
+ MaxKeys: 200,
819
+ }));
820
+
821
+ for (const obj of response.Contents || []) {
822
+ const key = obj.Key || '';
823
+ if (key.endsWith('/__next._tree.txt')) {
824
+ const dir = key.slice(0, key.length - '__next._tree.txt'.length);
825
+ referencePageCache.set(routePrefix, dir);
826
+ return dir;
827
+ }
828
+ }
829
+ } catch {
830
+ // S3 error — skip segment generation
831
+ }
832
+
833
+ referencePageCache.set(routePrefix, null);
834
+ return null;
835
+ }
836
+
837
+ type SegmentFileType = 'shared' | 'tree' | 'head' | 'full' | 'page';
838
+
839
+ /**
840
+ * Classify a segment file by its name.
841
+ */
842
+ function classifySegmentFile(name: string): SegmentFileType {
843
+ if (name === '__next._tree.txt') return 'tree';
844
+ if (name === '__next._head.txt') return 'head';
845
+ if (name === '__next._full.txt') return 'full';
846
+ if (name.includes('__PAGE__')) return 'page';
847
+ return 'shared';
848
+ }
849
+
850
+ /**
851
+ * List all segment file names in a reference directory.
852
+ */
853
+ async function listReferenceSegmentFiles(bucket: string, refDir: string): Promise<string[]> {
854
+ try {
855
+ const response = await s3.send(new ListObjectsV2Command({
856
+ Bucket: bucket,
857
+ Prefix: `${refDir}__next.`,
858
+ MaxKeys: 50,
859
+ }));
860
+
861
+ return (response.Contents || [])
862
+ .map(obj => obj.Key || '')
863
+ .filter(key => key.endsWith('.txt'))
864
+ .map(key => key.slice(refDir.length));
865
+ } catch {
866
+ return [];
867
+ }
868
+ }
869
+
870
+ /**
871
+ * Patch the _tree.txt segment: replace dynamic paramKey values for all params.
872
+ * Each dynamic segment has "name":"<paramName>","paramType":"d","paramKey":"<value>".
873
+ * We match on the param name to replace the correct paramKey for each param.
874
+ */
875
+ function patchTreeSegment(template: string, params: Record<string, string>): string {
876
+ let result = template;
877
+ for (const [name, value] of Object.entries(params)) {
878
+ result = result.replace(
879
+ new RegExp(`"name":"${name}","paramType":"d","paramKey":"[^"]*"`),
880
+ `"name":"${name}","paramType":"d","paramKey":"${value}"`
881
+ );
882
+ }
883
+ return result;
884
+ }
885
+
886
+ /**
887
+ * Patch the __PAGE__.txt segment: replace param values in the component props.
888
+ */
889
+ function patchPageSegment(template: string, params: Record<string, string>): string {
890
+ let result = template;
891
+ for (const [key, value] of Object.entries(params)) {
892
+ // RSC wire format: ["$","$L2",null,{"id":"OLD"}]
893
+ result = result.replace(
894
+ new RegExp(`"${key}":"[^"]*"`),
895
+ `"${key}":"${value}"`
896
+ );
897
+ }
898
+ return result;
899
+ }
900
+
901
+ /**
902
+ * Generate the _head.txt segment from a reference template and new metadata.
903
+ * Extracts preamble (module declarations) and buildId from the reference,
904
+ * rebuilds line 0 with new metadata.
905
+ */
906
+ function generateHeadSegment(template: string, metadata: QlaraMetadata): string {
907
+ // Split into lines — preamble is everything before the line starting with '0:'
908
+ const lines = template.split('\n');
909
+ const preambleLines: string[] = [];
910
+ let line0 = '';
911
+
912
+ for (const line of lines) {
913
+ if (line.startsWith('0:')) {
914
+ line0 = line;
915
+ } else if (line.trim()) {
916
+ preambleLines.push(line);
917
+ }
918
+ }
919
+
920
+ // Extract buildId from reference
921
+ const buildIdMatch = line0.match(/"buildId":"([^"]+)"/);
922
+ const buildId = buildIdMatch ? buildIdMatch[1] : '';
923
+
924
+ // Extract the viewport/charset meta tags from the reference (preserve them)
925
+ // These are inside the rsc at children[1] (the $L2 viewport boundary)
926
+ // We keep the structure identical but replace the metadata children
927
+ const viewportMatch = line0.match(/"children":\[(\["\\?\$","meta"[^\]]*\](?:,\["\\?\$","meta"[^\]]*\])*)\]/);
928
+ const viewportTags = viewportMatch ? viewportMatch[1] : '["\$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]';
929
+
930
+ // Build metadata RSC children array (standard JSON, not HTML-escaped)
931
+ const metaChildren: string[] = [];
932
+ let idx = 0;
933
+
934
+ // Title
935
+ metaChildren.push(`["$","title","${idx++}",{"children":"${escapeJson(metadata.title)}"}]`);
936
+
937
+ // Description
938
+ if (metadata.description) {
939
+ metaChildren.push(`["$","meta","${idx++}",{"name":"description","content":"${escapeJson(metadata.description)}"}]`);
940
+ }
941
+
942
+ // Open Graph
943
+ if (metadata.openGraph) {
944
+ const og = metadata.openGraph;
945
+ if (og.title) metaChildren.push(`["$","meta","${idx++}",{"property":"og:title","content":"${escapeJson(og.title)}"}]`);
946
+ if (og.description) metaChildren.push(`["$","meta","${idx++}",{"property":"og:description","content":"${escapeJson(og.description)}"}]`);
947
+ if (og.url) metaChildren.push(`["$","meta","${idx++}",{"property":"og:url","content":"${escapeJson(og.url)}"}]`);
948
+ if (og.siteName) metaChildren.push(`["$","meta","${idx++}",{"property":"og:site_name","content":"${escapeJson(og.siteName)}"}]`);
949
+ if (og.type) metaChildren.push(`["$","meta","${idx++}",{"property":"og:type","content":"${escapeJson(og.type)}"}]`);
950
+ for (const img of toArray(og.images)) {
951
+ if (typeof img === 'string') {
952
+ metaChildren.push(`["$","meta","${idx++}",{"property":"og:image","content":"${escapeJson(img)}"}]`);
953
+ } else {
954
+ metaChildren.push(`["$","meta","${idx++}",{"property":"og:image","content":"${escapeJson(img.url)}"}]`);
955
+ if (img.alt) metaChildren.push(`["$","meta","${idx++}",{"property":"og:image:alt","content":"${escapeJson(img.alt)}"}]`);
956
+ if (img.width !== undefined) metaChildren.push(`["$","meta","${idx++}",{"property":"og:image:width","content":"${String(img.width)}"}]`);
957
+ if (img.height !== undefined) metaChildren.push(`["$","meta","${idx++}",{"property":"og:image:height","content":"${String(img.height)}"}]`);
958
+ }
959
+ }
960
+ }
961
+
962
+ // Twitter
963
+ if (metadata.twitter) {
964
+ const tw = metadata.twitter;
965
+ const card = ('card' in tw && tw.card) ? tw.card : 'summary';
966
+ metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:card","content":"${escapeJson(card)}"}]`);
967
+ if (tw.title) metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:title","content":"${escapeJson(tw.title)}"}]`);
968
+ if (tw.description) metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:description","content":"${escapeJson(tw.description)}"}]`);
969
+ for (const img of toArray(tw.images)) {
970
+ if (typeof img === 'string') {
971
+ metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:image","content":"${escapeJson(img)}"}]`);
972
+ } else {
973
+ metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:image","content":"${escapeJson(img.url)}"}]`);
974
+ if (img.alt) metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:image:alt","content":"${escapeJson(img.alt)}"}]`);
975
+ }
976
+ }
977
+ }
978
+
979
+ // Reconstruct the _head.txt content by replacing the metadata children
980
+ // in the reference template's line 0 structure
981
+ const metaChildrenStr = metaChildren.join(',');
982
+
983
+ // The _head.txt line 0 structure (from analysis):
984
+ // 0:{"buildId":"...","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[viewport tags]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[METADATA HERE]}]}]}],null]}],"loading":null,"isPartial":false}
985
+ // Replace the "Next.Metadata" children array
986
+ const newLine0 = line0.replace(
987
+ /("name":"Next\.Metadata","children":\[)[\s\S]*?(\]\})/,
988
+ `$1${metaChildrenStr}$2`
989
+ );
990
+
991
+ return preambleLines.join('\n') + '\n' + newLine0 + '\n';
992
+ }
993
+
994
+ /**
995
+ * Generate per-segment prefetch files for a renderer-created page (Next.js 16+).
996
+ * Reads segment files from a build-time reference page, patches page-specific data,
997
+ * and uploads to the new page's subdirectory.
998
+ *
999
+ * Skips gracefully if no reference page exists (Next.js 15 or first deploy).
1000
+ */
1001
+ async function generateSegmentFiles(
1002
+ bucket: string,
1003
+ uri: string,
1004
+ params: Record<string, string>,
1005
+ rscData: string | null,
1006
+ metadata: QlaraMetadata | null,
1007
+ ): Promise<void> {
1008
+ const cleanUri = uri.replace(/^\//, '').replace(/\/$/, '');
1009
+ const parts = cleanUri.split('/');
1010
+ const routePrefix = parts.slice(0, -1).join('/'); // 'product'
1011
+ const segmentDir = `${cleanUri}/`; // 'product/42/'
1012
+
1013
+ // Find a build-time page with segment files to use as reference
1014
+ const refDir = await findReferenceSegmentDir(bucket, routePrefix);
1015
+ if (!refDir) return; // No segment files on S3 — Next.js 15 or no build-time pages
1016
+
1017
+ // List all segment files in the reference directory
1018
+ const fileNames = await listReferenceSegmentFiles(bucket, refDir);
1019
+ if (fileNames.length === 0) return;
1020
+
1021
+ // Read all reference files we need (skip _full — we use rscData directly)
1022
+ const filesToRead = fileNames.filter(name => classifySegmentFile(name) !== 'full');
1023
+ const readResults = await Promise.allSettled(
1024
+ filesToRead.map(async (name) => {
1025
+ const result = await s3.send(new GetObjectCommand({
1026
+ Bucket: bucket,
1027
+ Key: `${refDir}${name}`,
1028
+ }));
1029
+ return {
1030
+ name,
1031
+ content: await result.Body?.transformToString('utf-8') || '',
1032
+ };
1033
+ })
1034
+ );
1035
+
1036
+ const refMap = new Map<string, string>();
1037
+ for (const result of readResults) {
1038
+ if (result.status === 'fulfilled' && result.value.content) {
1039
+ refMap.set(result.value.name, result.value.content);
1040
+ }
1041
+ }
1042
+
1043
+ // Generate each segment file
1044
+ const uploads: Promise<unknown>[] = [];
1045
+ const cacheControl = `public, max-age=0, s-maxage=${__QLARA_CACHE_TTL__}, stale-while-revalidate=60`;
1046
+
1047
+ for (const name of fileNames) {
1048
+ let content: string | null = null;
1049
+ const type = classifySegmentFile(name);
1050
+
1051
+ switch (type) {
1052
+ case 'shared':
1053
+ content = refMap.get(name) || null;
1054
+ break;
1055
+ case 'tree': {
1056
+ const template = refMap.get(name);
1057
+ if (template) {
1058
+ content = patchTreeSegment(template, params);
1059
+ }
1060
+ break;
1061
+ }
1062
+ case 'head': {
1063
+ const template = refMap.get(name);
1064
+ if (template && metadata) {
1065
+ content = generateHeadSegment(template, metadata);
1066
+ }
1067
+ break;
1068
+ }
1069
+ case 'full':
1070
+ content = rscData;
1071
+ break;
1072
+ case 'page': {
1073
+ const template = refMap.get(name);
1074
+ if (template) {
1075
+ content = patchPageSegment(template, params);
1076
+ }
1077
+ break;
1078
+ }
1079
+ }
1080
+
1081
+ if (content) {
1082
+ uploads.push(
1083
+ s3.send(new PutObjectCommand({
1084
+ Bucket: bucket,
1085
+ Key: `${segmentDir}${name}`,
1086
+ Body: content,
1087
+ ContentType: 'text/plain; charset=utf-8',
1088
+ CacheControl: cacheControl,
1089
+ }))
1090
+ );
1091
+ }
1092
+ }
1093
+
1094
+ await Promise.all(uploads);
1095
+ }
1096
+
1097
+ /**
1098
+ * Escape a string for use inside a JSON string value.
1099
+ */
1100
+ function escapeJson(str: string): string {
1101
+ return str
1102
+ .replace(/\\/g, '\\\\')
1103
+ .replace(/"/g, '\\"')
1104
+ .replace(/\n/g, '\\n')
1105
+ .replace(/\r/g, '\\r')
1106
+ .replace(/\t/g, '\\t');
1107
+ }
1108
+
791
1109
  export async function handler(event: RendererEvent & { warmup?: boolean }): Promise<RendererResult> {
792
1110
  // Warmup invocation — just initialize the runtime and return
793
1111
  if (event.warmup) {
@@ -797,9 +1115,22 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
797
1115
  const { uri, bucket, routePattern, params } = event;
798
1116
 
799
1117
  try {
800
- // 0. Check if already rendered + read fallback in parallel
1118
+ // 0. Find route definition and run validation (if defined)
1119
+ const routeDef = routes?.find((r: { route: string }) => r.route === routePattern);
1120
+
1121
+ if (routeDef?.validate) {
1122
+ const isValid = await routeDef.validate(params);
1123
+ if (!isValid) {
1124
+ return {
1125
+ statusCode: 404,
1126
+ body: JSON.stringify({ error: `Validation failed for ${uri}`, params }),
1127
+ };
1128
+ }
1129
+ }
1130
+
1131
+ // 1. Check if already rendered + read fallback in parallel
801
1132
  const s3Key = deriveS3Key(uri);
802
- const fallbackKey = deriveFallbackKey(uri);
1133
+ const fallbackKey = deriveFallbackKey(routePattern);
803
1134
 
804
1135
  const [existingResult, fallbackResult] = await Promise.allSettled([
805
1136
  s3.send(new GetObjectCommand({ Bucket: bucket, Key: s3Key })),
@@ -836,18 +1167,18 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
836
1167
  };
837
1168
  }
838
1169
 
839
- // 2. Patch the fallback with the actual param value
840
- const paramValue = extractParamValue(uri);
841
- let html = fallbackHtml.replace(
842
- new RegExp(FALLBACK_PLACEHOLDER, 'g'),
843
- paramValue
844
- );
1170
+ // 2. Patch the fallback with actual param values (per-param placeholders)
1171
+ let html = fallbackHtml;
1172
+ for (const [name, value] of Object.entries(params)) {
1173
+ html = html.replace(new RegExp(paramPlaceholder(name), 'g'), value);
1174
+ }
845
1175
 
846
- // 3. Call the metaDataGenerator to fetch metadata from the data source
847
- const routeDef = routes?.find((r: { route: string }) => r.route === routePattern);
1176
+ // 3. Call generateMetadata (or deprecated metaDataGenerator) to fetch metadata
1177
+ const metadataFn = routeDef?.generateMetadata || routeDef?.metaDataGenerator;
1178
+ let metadata: QlaraMetadata | null = null;
848
1179
 
849
- if (routeDef?.metaDataGenerator) {
850
- const metadata = await routeDef.metaDataGenerator(params);
1180
+ if (metadataFn) {
1181
+ metadata = await metadataFn(params);
851
1182
  if (metadata) {
852
1183
  // 4. Patch the HTML with real metadata
853
1184
  html = patchMetadata(html, metadata);
@@ -866,9 +1197,8 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
866
1197
  );
867
1198
 
868
1199
  // 6. Framework-specific post-render uploads
869
- // Next.js: extract RSC flight data and upload as .txt for client-side navigation.
870
- // Next.js fetches .txt instead of .html when using <Link> / client-side nav.
871
- // Without this, client-side nav falls back to a full page reload (slow).
1200
+ // Next.js: extract RSC flight data, upload .txt for client-side navigation,
1201
+ // and generate per-segment prefetch files (Next.js 16+).
872
1202
  if (__QLARA_FRAMEWORK__ === 'next') {
873
1203
  const rscData = extractRscFlightData(html);
874
1204
  if (rscData) {
@@ -883,6 +1213,11 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
883
1213
  })
884
1214
  );
885
1215
  }
1216
+
1217
+ // 7. Generate per-segment prefetch files (Next.js 16+ Segment Cache)
1218
+ // Reads templates from a build-time reference page, patches page-specific data.
1219
+ // Skips automatically for Next.js 15 (no segment files on S3).
1220
+ await generateSegmentFiles(bucket, uri, params, rscData, metadata);
886
1221
  }
887
1222
 
888
1223
  return {
package/src/types.ts CHANGED
@@ -456,33 +456,63 @@ export interface QlaraMetadata {
456
456
  }
457
457
 
458
458
  /**
459
- * Function that fetches data for a dynamic route and returns metadata.
459
+ * Function that generates metadata for a dynamic route.
460
460
  * Equivalent to Next.js `generateMetadata()` — runs in the renderer Lambda
461
461
  * with access to the data source.
462
462
  *
463
- * @param params - The route parameters, e.g. { id: '42' } for /product/:id
463
+ * @param params - All route parameters, e.g. { lang: 'en', id: '42' } for /:lang/products/:id
464
464
  * @returns Metadata for the page, or null if the page doesn't exist
465
465
  */
466
466
  export type QlaraMetaDataGenerator = (params: Record<string, string>) => Promise<QlaraMetadata | null>;
467
467
 
468
- /** A single route definition with its pattern and metadata generator. */
468
+ /**
469
+ * Optional validation function for a dynamic route.
470
+ * Called before reading the fallback or generating metadata.
471
+ * Use this to cheaply reject invalid param combinations (e.g., unsupported languages).
472
+ *
473
+ * Receives ALL route parameters — you can validate every dynamic segment.
474
+ *
475
+ * @param params - All route parameters, e.g. { lang: 'en', id: '42' }
476
+ * @returns true if the params are valid, false to return 404 immediately
477
+ */
478
+ export type QlaraValidate = (params: Record<string, string>) => Promise<boolean>;
479
+
480
+ /**
481
+ * A single route definition at the leaf page level.
482
+ *
483
+ * Follows the Next.js "generate from bottom up" pattern:
484
+ * define one route entry per leaf page with ALL dynamic params.
485
+ */
469
486
  export interface QlaraRouteDefinition {
470
- /** Dynamic route pattern, e.g. '/product/:id' */
487
+ /** Dynamic route pattern, e.g. '/:lang/products/:id' */
471
488
  route: string;
472
- /** Function that fetches metadata for this route from the data source */
473
- metaDataGenerator: QlaraMetaDataGenerator;
489
+ /**
490
+ * Optional validation function — called before rendering.
491
+ * Return `false` to 404 immediately without generating the page.
492
+ * Receives all params so you can validate every dynamic segment.
493
+ */
494
+ validate?: QlaraValidate;
495
+ /**
496
+ * Function that generates metadata for this route.
497
+ * Preferred name — use this instead of `metaDataGenerator`.
498
+ */
499
+ generateMetadata?: QlaraMetaDataGenerator;
500
+ /**
501
+ * @deprecated Use `generateMetadata` instead. Kept for backward compatibility.
502
+ */
503
+ metaDataGenerator?: QlaraMetaDataGenerator;
474
504
  }
475
505
 
476
506
  /**
477
507
  * The route file default export type: an array of route definitions.
478
508
  *
479
- * Example:
509
+ * Example (single param):
480
510
  * ```typescript
481
511
  * import type { QlaraRoutes } from 'qlara';
482
512
  * const routes: QlaraRoutes = [
483
513
  * {
484
514
  * route: '/product/:id',
485
- * metaDataGenerator: async (params) => {
515
+ * generateMetadata: async (params) => {
486
516
  * const product = await getProduct(params.id);
487
517
  * if (!product) return null;
488
518
  * return { title: product.name, description: product.description };
@@ -491,6 +521,25 @@ export interface QlaraRouteDefinition {
491
521
  * ];
492
522
  * export default routes;
493
523
  * ```
524
+ *
525
+ * Example (multiple params with validation):
526
+ * ```typescript
527
+ * const routes: QlaraRoutes = [
528
+ * {
529
+ * route: '/:lang/products/:id',
530
+ * validate: async (params) => {
531
+ * if (!['en', 'da', 'de'].includes(params.lang)) return false;
532
+ * const product = await getProduct(params.id);
533
+ * return !!product;
534
+ * },
535
+ * generateMetadata: async (params) => {
536
+ * const product = await getProduct(params.id);
537
+ * if (!product) return null;
538
+ * return { title: `${product.name} | Store` };
539
+ * },
540
+ * },
541
+ * ];
542
+ * ```
494
543
  */
495
544
  export type QlaraRoutes = QlaraRouteDefinition[];
496
545