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 +94 -31
- package/dist/aws.d.cts +1 -1
- package/dist/aws.d.ts +1 -1
- package/dist/aws.js +95 -32
- package/dist/cli.js +97 -34
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/plugin/next.d.cts +1 -1
- package/dist/plugin/next.d.ts +1 -1
- package/dist/{types--KPPgCtc.d.cts → types-BmSR1R_Q.d.cts} +57 -9
- package/dist/{types--KPPgCtc.d.ts → types-BmSR1R_Q.d.ts} +57 -9
- package/package.json +1 -1
- package/src/provider/aws/renderer.ts +116 -40
- package/src/types.ts +57 -8
package/dist/aws.cjs
CHANGED
|
@@ -462,8 +462,25 @@ function buildTemplate(config) {
|
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
464
|
]
|
|
465
|
-
}
|
|
466
|
-
//
|
|
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
|
-
|
|
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}\\":\\"${
|
|
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}\\",\\"${
|
|
654
|
+
`[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
|
|
636
655
|
);
|
|
637
656
|
}
|
|
638
|
-
const
|
|
639
|
-
if (
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
660
|
-
|
|
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
|
|
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
|
|
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
package/dist/aws.d.ts
CHANGED
package/dist/aws.js
CHANGED
|
@@ -459,8 +459,25 @@ function buildTemplate(config) {
|
|
|
459
459
|
}
|
|
460
460
|
}
|
|
461
461
|
]
|
|
462
|
-
}
|
|
463
|
-
//
|
|
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
|
-
|
|
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}\\":\\"${
|
|
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}\\",\\"${
|
|
650
|
+
`[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
|
|
632
651
|
);
|
|
633
652
|
}
|
|
634
|
-
const
|
|
635
|
-
if (
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
|
656
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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}\\":\\"${
|
|
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}\\",\\"${
|
|
658
|
+
`[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
|
|
640
659
|
);
|
|
641
660
|
}
|
|
642
|
-
const
|
|
643
|
-
if (
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
664
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/dist/plugin/next.d.cts
CHANGED
package/dist/plugin/next.d.ts
CHANGED
|
@@ -315,31 +315,60 @@ interface QlaraMetadata {
|
|
|
315
315
|
other?: Record<string, string | number | (string | number)[]>;
|
|
316
316
|
}
|
|
317
317
|
/**
|
|
318
|
-
* Function that
|
|
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 -
|
|
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
|
-
/**
|
|
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. '/
|
|
344
|
+
/** Dynamic route pattern, e.g. '/:lang/products/:id' */
|
|
329
345
|
route: string;
|
|
330
|
-
/**
|
|
331
|
-
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
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 -
|
|
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
|
-
/**
|
|
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. '/
|
|
344
|
+
/** Dynamic route pattern, e.g. '/:lang/products/:id' */
|
|
329
345
|
route: string;
|
|
330
|
-
/**
|
|
331
|
-
|
|
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
|
-
*
|
|
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,
|
|
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
|
@@ -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
|
|
99
|
-
*
|
|
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(
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
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
|
-
*
|
|
110
|
-
*
|
|
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
|
|
113
|
-
|
|
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
|
|
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,
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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('/'); // '
|
|
1003
|
-
const segmentDir = `${cleanUri}/`; // '
|
|
1004
|
-
|
|
1005
|
-
// Find a build-time page with segment files to use as reference
|
|
1006
|
-
|
|
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,
|
|
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.
|
|
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(
|
|
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
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
new RegExp(
|
|
1154
|
-
|
|
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
|
|
1158
|
-
const
|
|
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 (
|
|
1162
|
-
metadata = await
|
|
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
|
|
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 -
|
|
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
|
-
/**
|
|
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. '/
|
|
487
|
+
/** Dynamic route pattern, e.g. '/:lang/products/:id' */
|
|
471
488
|
route: string;
|
|
472
|
-
/**
|
|
473
|
-
|
|
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
|
-
*
|
|
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
|
|