qlara 0.1.10 → 0.1.12

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
@@ -462,8 +462,25 @@ function buildTemplate(config) {
462
462
  }
463
463
  }
464
464
  ]
465
- }
466
- // No CustomErrorResponses the edge handler manages 403/404
465
+ },
466
+ // Serve the framework's 404 page (e.g., Next.js not-found.ts → 404.html)
467
+ // when S3 returns 403 (missing file) or 404. This covers:
468
+ // - Unknown routes not in the manifest
469
+ // - Validation failures (renderer abandons, no file uploaded)
470
+ CustomErrorResponses: [
471
+ {
472
+ ErrorCode: 403,
473
+ ResponseCode: 404,
474
+ ResponsePagePath: "/404.html",
475
+ ErrorCachingMinTTL: 10
476
+ },
477
+ {
478
+ ErrorCode: 404,
479
+ ResponseCode: 404,
480
+ ResponsePagePath: "/404.html",
481
+ ErrorCachingMinTTL: 10
482
+ }
483
+ ]
467
484
  }
468
485
  }
469
486
  }
@@ -599,7 +616,9 @@ var STACK_NAME_PREFIX = "qlara";
599
616
  var import_node_fs3 = require("fs");
600
617
  var import_node_path3 = require("path");
601
618
  var FALLBACK_FILENAME = "_fallback.html";
602
- var FALLBACK_PLACEHOLDER = "__QLARA_FALLBACK__";
619
+ function paramPlaceholder(paramName) {
620
+ return `__QLARA_FALLBACK_${paramName}__`;
621
+ }
603
622
  function generateFallbackFromTemplate(templateHtml, routePattern) {
604
623
  let fallback = templateHtml;
605
624
  const paramNames = (routePattern.match(/:([^/]+)/g) || []).map((m) => m.slice(1));
@@ -622,7 +641,7 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
622
641
  );
623
642
  fallback = fallback.replace(
624
643
  propsRegex,
625
- `{\\"${param}\\":\\"${FALLBACK_PLACEHOLDER}\\"}`
644
+ `{\\"${param}\\":\\"${paramPlaceholder(param)}\\"}`
626
645
  );
627
646
  }
628
647
  for (const param of paramNames) {
@@ -632,20 +651,30 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
632
651
  );
633
652
  fallback = fallback.replace(
634
653
  segmentRegex,
635
- `[\\"${param}\\",\\"${FALLBACK_PLACEHOLDER}\\",\\"d\\"]`
654
+ `[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
636
655
  );
637
656
  }
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}\\]`,
657
+ const allSegments = routePattern.split("/");
658
+ if (allSegments.length > 1) {
659
+ const regexParts = allSegments.map((seg) => {
660
+ if (seg.startsWith(":")) {
661
+ return `${q}[^"]*${q}`;
662
+ }
663
+ return `${q}${seg}${q}`;
664
+ });
665
+ const cArrayRegex = new RegExp(
666
+ `(${q}c${q}:\\[)${regexParts.join(",")}(\\])`,
643
667
  "g"
644
668
  );
645
- fallback = fallback.replace(
646
- urlSegmentRegex,
647
- `$1\\"${FALLBACK_PLACEHOLDER}\\"]`
648
- );
669
+ const replacementParts = allSegments.map((seg) => {
670
+ if (seg.startsWith(":")) {
671
+ const pName = seg.slice(1);
672
+ return `\\"${paramPlaceholder(pName)}\\"`;
673
+ }
674
+ return `\\"${seg}\\"`;
675
+ });
676
+ const replacement = `$1${replacementParts.join(",")}$2`;
677
+ fallback = fallback.replace(cArrayRegex, replacement);
649
678
  }
650
679
  fallback = fallback.replace(
651
680
  /8:\{\\"metadata\\":\[[\s\S]*?\],\\"error\\":null,\\"digest\\":\\"?\$undefined\\?"\}/,
@@ -653,33 +682,55 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
653
682
  );
654
683
  return fallback;
655
684
  }
685
+ function findTemplateForRoute(buildDir, routePattern) {
686
+ const segments = routePattern.replace(/^\//, "").split("/");
687
+ function walk(currentDir, segmentIndex) {
688
+ if (segmentIndex >= segments.length) return null;
689
+ if (!(0, import_node_fs3.existsSync)(currentDir)) return null;
690
+ const segment = segments[segmentIndex];
691
+ const isLast = segmentIndex === segments.length - 1;
692
+ const isDynamic = segment.startsWith(":");
693
+ if (isLast) {
694
+ const files = (0, import_node_fs3.readdirSync)(currentDir).filter(
695
+ (f) => f.endsWith(".html") && f !== FALLBACK_FILENAME
696
+ );
697
+ return files.length > 0 ? (0, import_node_path3.join)(currentDir, files[0]) : null;
698
+ }
699
+ if (isDynamic) {
700
+ const entries = (0, import_node_fs3.readdirSync)(currentDir, { withFileTypes: true });
701
+ for (const entry of entries) {
702
+ if (entry.isDirectory() && !entry.name.startsWith("_") && !entry.name.startsWith(".")) {
703
+ const result = walk((0, import_node_path3.join)(currentDir, entry.name), segmentIndex + 1);
704
+ if (result) return result;
705
+ }
706
+ }
707
+ return null;
708
+ }
709
+ return walk((0, import_node_path3.join)(currentDir, segment), segmentIndex + 1);
710
+ }
711
+ return walk(buildDir, 0);
712
+ }
656
713
  function generateFallbacks(buildDir, routes) {
657
714
  const generated = [];
658
715
  for (const route of routes) {
659
- const parts = route.pattern.replace(/^\//, "").split("/");
660
- const dirParts = parts.filter((p) => !p.startsWith(":"));
661
- const routeDir = (0, import_node_path3.join)(buildDir, ...dirParts);
662
- if (!(0, import_node_fs3.existsSync)(routeDir)) {
716
+ const templatePath = findTemplateForRoute(buildDir, route.pattern);
717
+ if (!templatePath) {
663
718
  console.warn(
664
- `[qlara] Warning: No output directory for route ${route.pattern} at ${routeDir}`
719
+ `[qlara] Warning: No HTML template found for route ${route.pattern}`
665
720
  );
666
721
  continue;
667
722
  }
668
- const files = (0, import_node_fs3.readdirSync)(routeDir).filter(
669
- (f) => f.endsWith(".html") && f !== FALLBACK_FILENAME
670
- );
671
- if (files.length === 0) {
672
- console.warn(
673
- `[qlara] Warning: No HTML files in ${routeDir} to create fallback template`
674
- );
675
- continue;
676
- }
677
- const templatePath = (0, import_node_path3.join)(routeDir, files[0]);
678
723
  const templateHtml = (0, import_node_fs3.readFileSync)(templatePath, "utf-8");
679
724
  const fallbackHtml = generateFallbackFromTemplate(templateHtml, route.pattern);
680
- const fallbackPath = (0, import_node_path3.join)(routeDir, FALLBACK_FILENAME);
725
+ const parts = route.pattern.replace(/^\//, "").split("/");
726
+ const dirParts = parts.filter((p) => !p.startsWith(":"));
727
+ const fallbackDir = dirParts.length > 0 ? (0, import_node_path3.join)(buildDir, ...dirParts) : buildDir;
728
+ if (!(0, import_node_fs3.existsSync)(fallbackDir)) {
729
+ (0, import_node_fs3.mkdirSync)(fallbackDir, { recursive: true });
730
+ }
731
+ const fallbackPath = (0, import_node_path3.join)(fallbackDir, FALLBACK_FILENAME);
681
732
  (0, import_node_fs3.writeFileSync)(fallbackPath, fallbackHtml);
682
- const relativePath = (0, import_node_path3.join)(...dirParts, FALLBACK_FILENAME);
733
+ const relativePath = dirParts.length > 0 ? (0, import_node_path3.join)(...dirParts, FALLBACK_FILENAME) : FALLBACK_FILENAME;
683
734
  generated.push(relativePath);
684
735
  console.log(`[qlara] Generated fallback: ${relativePath}`);
685
736
  }
@@ -777,6 +828,18 @@ async function updateCloudFrontEdgeVersion(cf, distributionId, newVersionArn) {
777
828
  const qlaraPolicyId = await ensureCachePolicy(cf);
778
829
  config.DefaultCacheBehavior.CachePolicyId = qlaraPolicyId;
779
830
  }
831
+ const hasErrorResponses = config.CustomErrorResponses?.Items?.some(
832
+ (r) => r.ErrorCode === 403
833
+ );
834
+ if (!hasErrorResponses) {
835
+ config.CustomErrorResponses = {
836
+ Quantity: 2,
837
+ Items: [
838
+ { ErrorCode: 403, ResponseCode: "404", ResponsePagePath: "/404.html", ErrorCachingMinTTL: 10 },
839
+ { ErrorCode: 404, ResponseCode: "404", ResponsePagePath: "/404.html", ErrorCachingMinTTL: 10 }
840
+ ]
841
+ };
842
+ }
780
843
  await cf.send(
781
844
  new import_client_cloudfront.UpdateDistributionCommand({
782
845
  Id: distributionId,
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
@@ -459,8 +459,25 @@ function buildTemplate(config) {
459
459
  }
460
460
  }
461
461
  ]
462
- }
463
- // No CustomErrorResponses the edge handler manages 403/404
462
+ },
463
+ // Serve the framework's 404 page (e.g., Next.js not-found.ts → 404.html)
464
+ // when S3 returns 403 (missing file) or 404. This covers:
465
+ // - Unknown routes not in the manifest
466
+ // - Validation failures (renderer abandons, no file uploaded)
467
+ CustomErrorResponses: [
468
+ {
469
+ ErrorCode: 403,
470
+ ResponseCode: 404,
471
+ ResponsePagePath: "/404.html",
472
+ ErrorCachingMinTTL: 10
473
+ },
474
+ {
475
+ ErrorCode: 404,
476
+ ResponseCode: 404,
477
+ ResponsePagePath: "/404.html",
478
+ ErrorCachingMinTTL: 10
479
+ }
480
+ ]
464
481
  }
465
482
  }
466
483
  }
@@ -592,10 +609,12 @@ async function bundleRenderer(routeFile, cacheTtl = 3600, framework) {
592
609
  var STACK_NAME_PREFIX = "qlara";
593
610
 
594
611
  // src/fallback.ts
595
- import { readFileSync as readFileSync3, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
612
+ import { readFileSync as readFileSync3, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
596
613
  import { join as join3 } from "path";
597
614
  var FALLBACK_FILENAME = "_fallback.html";
598
- var FALLBACK_PLACEHOLDER = "__QLARA_FALLBACK__";
615
+ function paramPlaceholder(paramName) {
616
+ return `__QLARA_FALLBACK_${paramName}__`;
617
+ }
599
618
  function generateFallbackFromTemplate(templateHtml, routePattern) {
600
619
  let fallback = templateHtml;
601
620
  const paramNames = (routePattern.match(/:([^/]+)/g) || []).map((m) => m.slice(1));
@@ -618,7 +637,7 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
618
637
  );
619
638
  fallback = fallback.replace(
620
639
  propsRegex,
621
- `{\\"${param}\\":\\"${FALLBACK_PLACEHOLDER}\\"}`
640
+ `{\\"${param}\\":\\"${paramPlaceholder(param)}\\"}`
622
641
  );
623
642
  }
624
643
  for (const param of paramNames) {
@@ -628,20 +647,30 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
628
647
  );
629
648
  fallback = fallback.replace(
630
649
  segmentRegex,
631
- `[\\"${param}\\",\\"${FALLBACK_PLACEHOLDER}\\",\\"d\\"]`
650
+ `[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
632
651
  );
633
652
  }
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}\\]`,
653
+ const allSegments = routePattern.split("/");
654
+ if (allSegments.length > 1) {
655
+ const regexParts = allSegments.map((seg) => {
656
+ if (seg.startsWith(":")) {
657
+ return `${q}[^"]*${q}`;
658
+ }
659
+ return `${q}${seg}${q}`;
660
+ });
661
+ const cArrayRegex = new RegExp(
662
+ `(${q}c${q}:\\[)${regexParts.join(",")}(\\])`,
639
663
  "g"
640
664
  );
641
- fallback = fallback.replace(
642
- urlSegmentRegex,
643
- `$1\\"${FALLBACK_PLACEHOLDER}\\"]`
644
- );
665
+ const replacementParts = allSegments.map((seg) => {
666
+ if (seg.startsWith(":")) {
667
+ const pName = seg.slice(1);
668
+ return `\\"${paramPlaceholder(pName)}\\"`;
669
+ }
670
+ return `\\"${seg}\\"`;
671
+ });
672
+ const replacement = `$1${replacementParts.join(",")}$2`;
673
+ fallback = fallback.replace(cArrayRegex, replacement);
645
674
  }
646
675
  fallback = fallback.replace(
647
676
  /8:\{\\"metadata\\":\[[\s\S]*?\],\\"error\\":null,\\"digest\\":\\"?\$undefined\\?"\}/,
@@ -649,33 +678,55 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
649
678
  );
650
679
  return fallback;
651
680
  }
681
+ function findTemplateForRoute(buildDir, routePattern) {
682
+ const segments = routePattern.replace(/^\//, "").split("/");
683
+ function walk(currentDir, segmentIndex) {
684
+ if (segmentIndex >= segments.length) return null;
685
+ if (!existsSync2(currentDir)) return null;
686
+ const segment = segments[segmentIndex];
687
+ const isLast = segmentIndex === segments.length - 1;
688
+ const isDynamic = segment.startsWith(":");
689
+ if (isLast) {
690
+ const files = readdirSync2(currentDir).filter(
691
+ (f) => f.endsWith(".html") && f !== FALLBACK_FILENAME
692
+ );
693
+ return files.length > 0 ? join3(currentDir, files[0]) : null;
694
+ }
695
+ if (isDynamic) {
696
+ const entries = readdirSync2(currentDir, { withFileTypes: true });
697
+ for (const entry of entries) {
698
+ if (entry.isDirectory() && !entry.name.startsWith("_") && !entry.name.startsWith(".")) {
699
+ const result = walk(join3(currentDir, entry.name), segmentIndex + 1);
700
+ if (result) return result;
701
+ }
702
+ }
703
+ return null;
704
+ }
705
+ return walk(join3(currentDir, segment), segmentIndex + 1);
706
+ }
707
+ return walk(buildDir, 0);
708
+ }
652
709
  function generateFallbacks(buildDir, routes) {
653
710
  const generated = [];
654
711
  for (const route of routes) {
655
- const parts = route.pattern.replace(/^\//, "").split("/");
656
- const dirParts = parts.filter((p) => !p.startsWith(":"));
657
- const routeDir = join3(buildDir, ...dirParts);
658
- if (!existsSync2(routeDir)) {
712
+ const templatePath = findTemplateForRoute(buildDir, route.pattern);
713
+ if (!templatePath) {
659
714
  console.warn(
660
- `[qlara] Warning: No output directory for route ${route.pattern} at ${routeDir}`
715
+ `[qlara] Warning: No HTML template found for route ${route.pattern}`
661
716
  );
662
717
  continue;
663
718
  }
664
- const files = readdirSync2(routeDir).filter(
665
- (f) => f.endsWith(".html") && f !== FALLBACK_FILENAME
666
- );
667
- if (files.length === 0) {
668
- console.warn(
669
- `[qlara] Warning: No HTML files in ${routeDir} to create fallback template`
670
- );
671
- continue;
672
- }
673
- const templatePath = join3(routeDir, files[0]);
674
719
  const templateHtml = readFileSync3(templatePath, "utf-8");
675
720
  const fallbackHtml = generateFallbackFromTemplate(templateHtml, route.pattern);
676
- const fallbackPath = join3(routeDir, FALLBACK_FILENAME);
721
+ const parts = route.pattern.replace(/^\//, "").split("/");
722
+ const dirParts = parts.filter((p) => !p.startsWith(":"));
723
+ const fallbackDir = dirParts.length > 0 ? join3(buildDir, ...dirParts) : buildDir;
724
+ if (!existsSync2(fallbackDir)) {
725
+ mkdirSync2(fallbackDir, { recursive: true });
726
+ }
727
+ const fallbackPath = join3(fallbackDir, FALLBACK_FILENAME);
677
728
  writeFileSync(fallbackPath, fallbackHtml);
678
- const relativePath = join3(...dirParts, FALLBACK_FILENAME);
729
+ const relativePath = dirParts.length > 0 ? join3(...dirParts, FALLBACK_FILENAME) : FALLBACK_FILENAME;
679
730
  generated.push(relativePath);
680
731
  console.log(`[qlara] Generated fallback: ${relativePath}`);
681
732
  }
@@ -773,6 +824,18 @@ async function updateCloudFrontEdgeVersion(cf, distributionId, newVersionArn) {
773
824
  const qlaraPolicyId = await ensureCachePolicy(cf);
774
825
  config.DefaultCacheBehavior.CachePolicyId = qlaraPolicyId;
775
826
  }
827
+ const hasErrorResponses = config.CustomErrorResponses?.Items?.some(
828
+ (r) => r.ErrorCode === 403
829
+ );
830
+ if (!hasErrorResponses) {
831
+ config.CustomErrorResponses = {
832
+ Quantity: 2,
833
+ Items: [
834
+ { ErrorCode: 403, ResponseCode: "404", ResponsePagePath: "/404.html", ErrorCachingMinTTL: 10 },
835
+ { ErrorCode: 404, ResponseCode: "404", ResponsePagePath: "/404.html", ErrorCachingMinTTL: 10 }
836
+ ]
837
+ };
838
+ }
776
839
  await cf.send(
777
840
  new UpdateDistributionCommand({
778
841
  Id: distributionId,
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
4
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync3 } from "fs";
5
5
  import { join as join4 } from "path";
6
6
 
7
7
  // src/provider/aws/constants.ts
@@ -470,8 +470,25 @@ function buildTemplate(config) {
470
470
  }
471
471
  }
472
472
  ]
473
- }
474
- // No CustomErrorResponses the edge handler manages 403/404
473
+ },
474
+ // Serve the framework's 404 page (e.g., Next.js not-found.ts → 404.html)
475
+ // when S3 returns 403 (missing file) or 404. This covers:
476
+ // - Unknown routes not in the manifest
477
+ // - Validation failures (renderer abandons, no file uploaded)
478
+ CustomErrorResponses: [
479
+ {
480
+ ErrorCode: 403,
481
+ ResponseCode: 404,
482
+ ResponsePagePath: "/404.html",
483
+ ErrorCachingMinTTL: 10
484
+ },
485
+ {
486
+ ErrorCode: 404,
487
+ ResponseCode: 404,
488
+ ResponsePagePath: "/404.html",
489
+ ErrorCachingMinTTL: 10
490
+ }
491
+ ]
475
492
  }
476
493
  }
477
494
  }
@@ -600,10 +617,12 @@ async function bundleRenderer(routeFile, cacheTtl = 3600, framework) {
600
617
  }
601
618
 
602
619
  // src/fallback.ts
603
- import { readFileSync as readFileSync3, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
620
+ import { readFileSync as readFileSync3, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
604
621
  import { join as join3 } from "path";
605
622
  var FALLBACK_FILENAME = "_fallback.html";
606
- var FALLBACK_PLACEHOLDER = "__QLARA_FALLBACK__";
623
+ function paramPlaceholder(paramName) {
624
+ return `__QLARA_FALLBACK_${paramName}__`;
625
+ }
607
626
  function generateFallbackFromTemplate(templateHtml, routePattern) {
608
627
  let fallback = templateHtml;
609
628
  const paramNames = (routePattern.match(/:([^/]+)/g) || []).map((m) => m.slice(1));
@@ -626,7 +645,7 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
626
645
  );
627
646
  fallback = fallback.replace(
628
647
  propsRegex,
629
- `{\\"${param}\\":\\"${FALLBACK_PLACEHOLDER}\\"}`
648
+ `{\\"${param}\\":\\"${paramPlaceholder(param)}\\"}`
630
649
  );
631
650
  }
632
651
  for (const param of paramNames) {
@@ -636,20 +655,30 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
636
655
  );
637
656
  fallback = fallback.replace(
638
657
  segmentRegex,
639
- `[\\"${param}\\",\\"${FALLBACK_PLACEHOLDER}\\",\\"d\\"]`
658
+ `[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
640
659
  );
641
660
  }
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}\\]`,
661
+ const allSegments = routePattern.split("/");
662
+ if (allSegments.length > 1) {
663
+ const regexParts = allSegments.map((seg) => {
664
+ if (seg.startsWith(":")) {
665
+ return `${q}[^"]*${q}`;
666
+ }
667
+ return `${q}${seg}${q}`;
668
+ });
669
+ const cArrayRegex = new RegExp(
670
+ `(${q}c${q}:\\[)${regexParts.join(",")}(\\])`,
647
671
  "g"
648
672
  );
649
- fallback = fallback.replace(
650
- urlSegmentRegex,
651
- `$1\\"${FALLBACK_PLACEHOLDER}\\"]`
652
- );
673
+ const replacementParts = allSegments.map((seg) => {
674
+ if (seg.startsWith(":")) {
675
+ const pName = seg.slice(1);
676
+ return `\\"${paramPlaceholder(pName)}\\"`;
677
+ }
678
+ return `\\"${seg}\\"`;
679
+ });
680
+ const replacement = `$1${replacementParts.join(",")}$2`;
681
+ fallback = fallback.replace(cArrayRegex, replacement);
653
682
  }
654
683
  fallback = fallback.replace(
655
684
  /8:\{\\"metadata\\":\[[\s\S]*?\],\\"error\\":null,\\"digest\\":\\"?\$undefined\\?"\}/,
@@ -657,33 +686,55 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
657
686
  );
658
687
  return fallback;
659
688
  }
689
+ function findTemplateForRoute(buildDir, routePattern) {
690
+ const segments = routePattern.replace(/^\//, "").split("/");
691
+ function walk(currentDir, segmentIndex) {
692
+ if (segmentIndex >= segments.length) return null;
693
+ if (!existsSync2(currentDir)) return null;
694
+ const segment = segments[segmentIndex];
695
+ const isLast = segmentIndex === segments.length - 1;
696
+ const isDynamic = segment.startsWith(":");
697
+ if (isLast) {
698
+ const files = readdirSync2(currentDir).filter(
699
+ (f) => f.endsWith(".html") && f !== FALLBACK_FILENAME
700
+ );
701
+ return files.length > 0 ? join3(currentDir, files[0]) : null;
702
+ }
703
+ if (isDynamic) {
704
+ const entries = readdirSync2(currentDir, { withFileTypes: true });
705
+ for (const entry of entries) {
706
+ if (entry.isDirectory() && !entry.name.startsWith("_") && !entry.name.startsWith(".")) {
707
+ const result = walk(join3(currentDir, entry.name), segmentIndex + 1);
708
+ if (result) return result;
709
+ }
710
+ }
711
+ return null;
712
+ }
713
+ return walk(join3(currentDir, segment), segmentIndex + 1);
714
+ }
715
+ return walk(buildDir, 0);
716
+ }
660
717
  function generateFallbacks(buildDir, routes) {
661
718
  const generated = [];
662
719
  for (const route of routes) {
663
- const parts = route.pattern.replace(/^\//, "").split("/");
664
- const dirParts = parts.filter((p) => !p.startsWith(":"));
665
- const routeDir = join3(buildDir, ...dirParts);
666
- if (!existsSync2(routeDir)) {
720
+ const templatePath = findTemplateForRoute(buildDir, route.pattern);
721
+ if (!templatePath) {
667
722
  console.warn(
668
- `[qlara] Warning: No output directory for route ${route.pattern} at ${routeDir}`
723
+ `[qlara] Warning: No HTML template found for route ${route.pattern}`
669
724
  );
670
725
  continue;
671
726
  }
672
- const files = readdirSync2(routeDir).filter(
673
- (f) => f.endsWith(".html") && f !== FALLBACK_FILENAME
674
- );
675
- if (files.length === 0) {
676
- console.warn(
677
- `[qlara] Warning: No HTML files in ${routeDir} to create fallback template`
678
- );
679
- continue;
680
- }
681
- const templatePath = join3(routeDir, files[0]);
682
727
  const templateHtml = readFileSync3(templatePath, "utf-8");
683
728
  const fallbackHtml = generateFallbackFromTemplate(templateHtml, route.pattern);
684
- const fallbackPath = join3(routeDir, FALLBACK_FILENAME);
729
+ const parts = route.pattern.replace(/^\//, "").split("/");
730
+ const dirParts = parts.filter((p) => !p.startsWith(":"));
731
+ const fallbackDir = dirParts.length > 0 ? join3(buildDir, ...dirParts) : buildDir;
732
+ if (!existsSync2(fallbackDir)) {
733
+ mkdirSync2(fallbackDir, { recursive: true });
734
+ }
735
+ const fallbackPath = join3(fallbackDir, FALLBACK_FILENAME);
685
736
  writeFileSync(fallbackPath, fallbackHtml);
686
- const relativePath = join3(...dirParts, FALLBACK_FILENAME);
737
+ const relativePath = dirParts.length > 0 ? join3(...dirParts, FALLBACK_FILENAME) : FALLBACK_FILENAME;
687
738
  generated.push(relativePath);
688
739
  console.log(`[qlara] Generated fallback: ${relativePath}`);
689
740
  }
@@ -781,6 +832,18 @@ async function updateCloudFrontEdgeVersion(cf, distributionId, newVersionArn) {
781
832
  const qlaraPolicyId = await ensureCachePolicy(cf);
782
833
  config.DefaultCacheBehavior.CachePolicyId = qlaraPolicyId;
783
834
  }
835
+ const hasErrorResponses = config.CustomErrorResponses?.Items?.some(
836
+ (r) => r.ErrorCode === 403
837
+ );
838
+ if (!hasErrorResponses) {
839
+ config.CustomErrorResponses = {
840
+ Quantity: 2,
841
+ Items: [
842
+ { ErrorCode: 403, ResponseCode: "404", ResponsePagePath: "/404.html", ErrorCachingMinTTL: 10 },
843
+ { ErrorCode: 404, ResponseCode: "404", ResponsePagePath: "/404.html", ErrorCachingMinTTL: 10 }
844
+ ]
845
+ };
846
+ }
784
847
  await cf.send(
785
848
  new UpdateDistributionCommand({
786
849
  Id: distributionId,
@@ -1149,7 +1212,7 @@ function loadResources() {
1149
1212
  return JSON.parse(readFileSync4(RESOURCES_PATH, "utf-8"));
1150
1213
  }
1151
1214
  function saveResources(resources) {
1152
- mkdirSync2(QLARA_DIR, { recursive: true });
1215
+ mkdirSync3(QLARA_DIR, { recursive: true });
1153
1216
  writeFileSync2(RESOURCES_PATH, JSON.stringify(resources, null, 2));
1154
1217
  }
1155
1218
  async function deploy() {
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.10",
3
+ "version": "0.1.12",
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": [
@@ -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 ────────────────────
@@ -832,6 +834,58 @@ async function findReferenceSegmentDir(bucket: string, routePrefix: string): Pro
832
834
  return null;
833
835
  }
834
836
 
837
+ /**
838
+ * Find a reference segment directory using the route pattern.
839
+ * For multi-param routes like /:lang/products/:id, the direct URI-based prefix
840
+ * (e.g., 'da/products') may not contain any build-time pages. This function
841
+ * walks the S3 key hierarchy following the route pattern: static segments
842
+ * descend directly, dynamic segments list subdirectories and try any of them.
843
+ */
844
+ async function findReferenceSegmentDirByPattern(
845
+ bucket: string,
846
+ routePattern: string,
847
+ ): Promise<string | null> {
848
+ // Remove the last segment (the page-level param) to get the parent segments
849
+ const segments = routePattern.replace(/^\//, '').split('/');
850
+ const parentSegments = segments.slice(0, -1);
851
+
852
+ async function searchPrefix(prefix: string, segmentIndex: number): Promise<string | null> {
853
+ if (segmentIndex >= parentSegments.length) {
854
+ // We've traversed all parent segments — check for segment files here
855
+ return findReferenceSegmentDir(bucket, prefix.replace(/\/$/, ''));
856
+ }
857
+
858
+ const segment = parentSegments[segmentIndex];
859
+
860
+ if (!segment.startsWith(':')) {
861
+ // Static segment — descend directly
862
+ const nextPrefix = prefix ? `${prefix}${segment}/` : `${segment}/`;
863
+ return searchPrefix(nextPrefix, segmentIndex + 1);
864
+ }
865
+
866
+ // Dynamic segment — list subdirectories and try each
867
+ try {
868
+ const response = await s3.send(new ListObjectsV2Command({
869
+ Bucket: bucket,
870
+ Prefix: prefix,
871
+ Delimiter: '/',
872
+ MaxKeys: 10,
873
+ }));
874
+
875
+ for (const commonPrefix of response.CommonPrefixes || []) {
876
+ const subPrefix = commonPrefix.Prefix || '';
877
+ const result = await searchPrefix(subPrefix, segmentIndex + 1);
878
+ if (result) return result;
879
+ }
880
+ } catch {
881
+ // S3 error — skip
882
+ }
883
+ return null;
884
+ }
885
+
886
+ return searchPrefix('', 0);
887
+ }
888
+
835
889
  type SegmentFileType = 'shared' | 'tree' | 'head' | 'full' | 'page';
836
890
 
837
891
  /**
@@ -866,13 +920,19 @@ async function listReferenceSegmentFiles(bucket: string, refDir: string): Promis
866
920
  }
867
921
 
868
922
  /**
869
- * Patch the _tree.txt segment: replace the dynamic paramKey value.
923
+ * Patch the _tree.txt segment: replace dynamic paramKey values for all params.
924
+ * Each dynamic segment has "name":"<paramName>","paramType":"d","paramKey":"<value>".
925
+ * We match on the param name to replace the correct paramKey for each param.
870
926
  */
871
- function patchTreeSegment(template: string, newValue: string): string {
872
- return template.replace(
873
- /"paramType":"d","paramKey":"[^"]*"/,
874
- `"paramType":"d","paramKey":"${newValue}"`
875
- );
927
+ function patchTreeSegment(template: string, params: Record<string, string>): string {
928
+ let result = template;
929
+ for (const [name, value] of Object.entries(params)) {
930
+ result = result.replace(
931
+ new RegExp(`"name":"${name}","paramType":"d","paramKey":"[^"]*"`),
932
+ `"name":"${name}","paramType":"d","paramKey":"${value}"`
933
+ );
934
+ }
935
+ return result;
876
936
  }
877
937
 
878
938
  /**
@@ -996,14 +1056,19 @@ async function generateSegmentFiles(
996
1056
  params: Record<string, string>,
997
1057
  rscData: string | null,
998
1058
  metadata: QlaraMetadata | null,
1059
+ routePattern: string,
999
1060
  ): Promise<void> {
1000
1061
  const cleanUri = uri.replace(/^\//, '').replace(/\/$/, '');
1001
1062
  const parts = cleanUri.split('/');
1002
- const routePrefix = parts.slice(0, -1).join('/'); // 'product'
1003
- const segmentDir = `${cleanUri}/`; // 'product/42/'
1004
-
1005
- // Find a build-time page with segment files to use as reference
1006
- const refDir = await findReferenceSegmentDir(bucket, routePrefix);
1063
+ const routePrefix = parts.slice(0, -1).join('/'); // 'da/products'
1064
+ const segmentDir = `${cleanUri}/`; // 'da/products/42/'
1065
+
1066
+ // Find a build-time page with segment files to use as reference.
1067
+ // Try the direct URI prefix first; fall back to pattern-based search for multi-param routes.
1068
+ let refDir = await findReferenceSegmentDir(bucket, routePrefix);
1069
+ if (!refDir) {
1070
+ refDir = await findReferenceSegmentDirByPattern(bucket, routePattern);
1071
+ }
1007
1072
  if (!refDir) return; // No segment files on S3 — Next.js 15 or no build-time pages
1008
1073
 
1009
1074
  // List all segment files in the reference directory
@@ -1033,7 +1098,6 @@ async function generateSegmentFiles(
1033
1098
  }
1034
1099
 
1035
1100
  // Generate each segment file
1036
- const paramValue = parts[parts.length - 1]; // last path segment = dynamic param value
1037
1101
  const uploads: Promise<unknown>[] = [];
1038
1102
  const cacheControl = `public, max-age=0, s-maxage=${__QLARA_CACHE_TTL__}, stale-while-revalidate=60`;
1039
1103
 
@@ -1048,7 +1112,7 @@ async function generateSegmentFiles(
1048
1112
  case 'tree': {
1049
1113
  const template = refMap.get(name);
1050
1114
  if (template) {
1051
- content = patchTreeSegment(template, paramValue);
1115
+ content = patchTreeSegment(template, params);
1052
1116
  }
1053
1117
  break;
1054
1118
  }
@@ -1108,9 +1172,22 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
1108
1172
  const { uri, bucket, routePattern, params } = event;
1109
1173
 
1110
1174
  try {
1111
- // 0. Check if already rendered + read fallback in parallel
1175
+ // 0. Find route definition and run validation (if defined)
1176
+ const routeDef = routes?.find((r: { route: string }) => r.route === routePattern);
1177
+
1178
+ if (routeDef?.validate) {
1179
+ const isValid = await routeDef.validate(params);
1180
+ if (!isValid) {
1181
+ return {
1182
+ statusCode: 404,
1183
+ body: JSON.stringify({ error: `Validation failed for ${uri}`, params }),
1184
+ };
1185
+ }
1186
+ }
1187
+
1188
+ // 1. Check if already rendered + read fallback in parallel
1112
1189
  const s3Key = deriveS3Key(uri);
1113
- const fallbackKey = deriveFallbackKey(uri);
1190
+ const fallbackKey = deriveFallbackKey(routePattern);
1114
1191
 
1115
1192
  const [existingResult, fallbackResult] = await Promise.allSettled([
1116
1193
  s3.send(new GetObjectCommand({ Bucket: bucket, Key: s3Key })),
@@ -1147,19 +1224,18 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
1147
1224
  };
1148
1225
  }
1149
1226
 
1150
- // 2. Patch the fallback with the actual param value
1151
- const paramValue = extractParamValue(uri);
1152
- let html = fallbackHtml.replace(
1153
- new RegExp(FALLBACK_PLACEHOLDER, 'g'),
1154
- paramValue
1155
- );
1227
+ // 2. Patch the fallback with actual param values (per-param placeholders)
1228
+ let html = fallbackHtml;
1229
+ for (const [name, value] of Object.entries(params)) {
1230
+ html = html.replace(new RegExp(paramPlaceholder(name), 'g'), value);
1231
+ }
1156
1232
 
1157
- // 3. Call the metaDataGenerator to fetch metadata from the data source
1158
- const routeDef = routes?.find((r: { route: string }) => r.route === routePattern);
1233
+ // 3. Call generateMetadata (or deprecated metaDataGenerator) to fetch metadata
1234
+ const metadataFn = routeDef?.generateMetadata || routeDef?.metaDataGenerator;
1159
1235
  let metadata: QlaraMetadata | null = null;
1160
1236
 
1161
- if (routeDef?.metaDataGenerator) {
1162
- metadata = await routeDef.metaDataGenerator(params);
1237
+ if (metadataFn) {
1238
+ metadata = await metadataFn(params);
1163
1239
  if (metadata) {
1164
1240
  // 4. Patch the HTML with real metadata
1165
1241
  html = patchMetadata(html, metadata);
@@ -1198,7 +1274,7 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
1198
1274
  // 7. Generate per-segment prefetch files (Next.js 16+ Segment Cache)
1199
1275
  // Reads templates from a build-time reference page, patches page-specific data.
1200
1276
  // Skips automatically for Next.js 15 (no segment files on S3).
1201
- await generateSegmentFiles(bucket, uri, params, rscData, metadata);
1277
+ await generateSegmentFiles(bucket, uri, params, rscData, metadata, routePattern);
1202
1278
  }
1203
1279
 
1204
1280
  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