qlara 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aws.cjs +24 -12
- package/dist/aws.d.cts +1 -1
- package/dist/aws.d.ts +1 -1
- package/dist/aws.js +24 -12
- package/dist/cli.js +24 -12
- 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 +365 -30
- package/src/types.ts +57 -8
package/dist/aws.cjs
CHANGED
|
@@ -599,7 +599,9 @@ var STACK_NAME_PREFIX = "qlara";
|
|
|
599
599
|
var import_node_fs3 = require("fs");
|
|
600
600
|
var import_node_path3 = require("path");
|
|
601
601
|
var FALLBACK_FILENAME = "_fallback.html";
|
|
602
|
-
|
|
602
|
+
function paramPlaceholder(paramName) {
|
|
603
|
+
return `__QLARA_FALLBACK_${paramName}__`;
|
|
604
|
+
}
|
|
603
605
|
function generateFallbackFromTemplate(templateHtml, routePattern) {
|
|
604
606
|
let fallback = templateHtml;
|
|
605
607
|
const paramNames = (routePattern.match(/:([^/]+)/g) || []).map((m) => m.slice(1));
|
|
@@ -622,7 +624,7 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
|
|
|
622
624
|
);
|
|
623
625
|
fallback = fallback.replace(
|
|
624
626
|
propsRegex,
|
|
625
|
-
`{\\"${param}\\":\\"${
|
|
627
|
+
`{\\"${param}\\":\\"${paramPlaceholder(param)}\\"}`
|
|
626
628
|
);
|
|
627
629
|
}
|
|
628
630
|
for (const param of paramNames) {
|
|
@@ -632,20 +634,30 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
|
|
|
632
634
|
);
|
|
633
635
|
fallback = fallback.replace(
|
|
634
636
|
segmentRegex,
|
|
635
|
-
`[\\"${param}\\",\\"${
|
|
637
|
+
`[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
|
|
636
638
|
);
|
|
637
639
|
}
|
|
638
|
-
const
|
|
639
|
-
if (
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
640
|
+
const allSegments = routePattern.split("/");
|
|
641
|
+
if (allSegments.length > 1) {
|
|
642
|
+
const regexParts = allSegments.map((seg) => {
|
|
643
|
+
if (seg.startsWith(":")) {
|
|
644
|
+
return `${q}[^"]*${q}`;
|
|
645
|
+
}
|
|
646
|
+
return `${q}${seg}${q}`;
|
|
647
|
+
});
|
|
648
|
+
const cArrayRegex = new RegExp(
|
|
649
|
+
`(${q}c${q}:\\[)${regexParts.join(",")}(\\])`,
|
|
643
650
|
"g"
|
|
644
651
|
);
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
652
|
+
const replacementParts = allSegments.map((seg) => {
|
|
653
|
+
if (seg.startsWith(":")) {
|
|
654
|
+
const pName = seg.slice(1);
|
|
655
|
+
return `\\"${paramPlaceholder(pName)}\\"`;
|
|
656
|
+
}
|
|
657
|
+
return `\\"${seg}\\"`;
|
|
658
|
+
});
|
|
659
|
+
const replacement = `$1${replacementParts.join(",")}$2`;
|
|
660
|
+
fallback = fallback.replace(cArrayRegex, replacement);
|
|
649
661
|
}
|
|
650
662
|
fallback = fallback.replace(
|
|
651
663
|
/8:\{\\"metadata\\":\[[\s\S]*?\],\\"error\\":null,\\"digest\\":\\"?\$undefined\\?"\}/,
|
package/dist/aws.d.cts
CHANGED
package/dist/aws.d.ts
CHANGED
package/dist/aws.js
CHANGED
|
@@ -595,7 +595,9 @@ var STACK_NAME_PREFIX = "qlara";
|
|
|
595
595
|
import { readFileSync as readFileSync3, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
|
|
596
596
|
import { join as join3 } from "path";
|
|
597
597
|
var FALLBACK_FILENAME = "_fallback.html";
|
|
598
|
-
|
|
598
|
+
function paramPlaceholder(paramName) {
|
|
599
|
+
return `__QLARA_FALLBACK_${paramName}__`;
|
|
600
|
+
}
|
|
599
601
|
function generateFallbackFromTemplate(templateHtml, routePattern) {
|
|
600
602
|
let fallback = templateHtml;
|
|
601
603
|
const paramNames = (routePattern.match(/:([^/]+)/g) || []).map((m) => m.slice(1));
|
|
@@ -618,7 +620,7 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
|
|
|
618
620
|
);
|
|
619
621
|
fallback = fallback.replace(
|
|
620
622
|
propsRegex,
|
|
621
|
-
`{\\"${param}\\":\\"${
|
|
623
|
+
`{\\"${param}\\":\\"${paramPlaceholder(param)}\\"}`
|
|
622
624
|
);
|
|
623
625
|
}
|
|
624
626
|
for (const param of paramNames) {
|
|
@@ -628,20 +630,30 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
|
|
|
628
630
|
);
|
|
629
631
|
fallback = fallback.replace(
|
|
630
632
|
segmentRegex,
|
|
631
|
-
`[\\"${param}\\",\\"${
|
|
633
|
+
`[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
|
|
632
634
|
);
|
|
633
635
|
}
|
|
634
|
-
const
|
|
635
|
-
if (
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
636
|
+
const allSegments = routePattern.split("/");
|
|
637
|
+
if (allSegments.length > 1) {
|
|
638
|
+
const regexParts = allSegments.map((seg) => {
|
|
639
|
+
if (seg.startsWith(":")) {
|
|
640
|
+
return `${q}[^"]*${q}`;
|
|
641
|
+
}
|
|
642
|
+
return `${q}${seg}${q}`;
|
|
643
|
+
});
|
|
644
|
+
const cArrayRegex = new RegExp(
|
|
645
|
+
`(${q}c${q}:\\[)${regexParts.join(",")}(\\])`,
|
|
639
646
|
"g"
|
|
640
647
|
);
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
648
|
+
const replacementParts = allSegments.map((seg) => {
|
|
649
|
+
if (seg.startsWith(":")) {
|
|
650
|
+
const pName = seg.slice(1);
|
|
651
|
+
return `\\"${paramPlaceholder(pName)}\\"`;
|
|
652
|
+
}
|
|
653
|
+
return `\\"${seg}\\"`;
|
|
654
|
+
});
|
|
655
|
+
const replacement = `$1${replacementParts.join(",")}$2`;
|
|
656
|
+
fallback = fallback.replace(cArrayRegex, replacement);
|
|
645
657
|
}
|
|
646
658
|
fallback = fallback.replace(
|
|
647
659
|
/8:\{\\"metadata\\":\[[\s\S]*?\],\\"error\\":null,\\"digest\\":\\"?\$undefined\\?"\}/,
|
package/dist/cli.js
CHANGED
|
@@ -603,7 +603,9 @@ async function bundleRenderer(routeFile, cacheTtl = 3600, framework) {
|
|
|
603
603
|
import { readFileSync as readFileSync3, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
|
|
604
604
|
import { join as join3 } from "path";
|
|
605
605
|
var FALLBACK_FILENAME = "_fallback.html";
|
|
606
|
-
|
|
606
|
+
function paramPlaceholder(paramName) {
|
|
607
|
+
return `__QLARA_FALLBACK_${paramName}__`;
|
|
608
|
+
}
|
|
607
609
|
function generateFallbackFromTemplate(templateHtml, routePattern) {
|
|
608
610
|
let fallback = templateHtml;
|
|
609
611
|
const paramNames = (routePattern.match(/:([^/]+)/g) || []).map((m) => m.slice(1));
|
|
@@ -626,7 +628,7 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
|
|
|
626
628
|
);
|
|
627
629
|
fallback = fallback.replace(
|
|
628
630
|
propsRegex,
|
|
629
|
-
`{\\"${param}\\":\\"${
|
|
631
|
+
`{\\"${param}\\":\\"${paramPlaceholder(param)}\\"}`
|
|
630
632
|
);
|
|
631
633
|
}
|
|
632
634
|
for (const param of paramNames) {
|
|
@@ -636,20 +638,30 @@ function generateFallbackFromTemplate(templateHtml, routePattern) {
|
|
|
636
638
|
);
|
|
637
639
|
fallback = fallback.replace(
|
|
638
640
|
segmentRegex,
|
|
639
|
-
`[\\"${param}\\",\\"${
|
|
641
|
+
`[\\"${param}\\",\\"${paramPlaceholder(param)}\\",\\"d\\"]`
|
|
640
642
|
);
|
|
641
643
|
}
|
|
642
|
-
const
|
|
643
|
-
if (
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
644
|
+
const allSegments = routePattern.split("/");
|
|
645
|
+
if (allSegments.length > 1) {
|
|
646
|
+
const regexParts = allSegments.map((seg) => {
|
|
647
|
+
if (seg.startsWith(":")) {
|
|
648
|
+
return `${q}[^"]*${q}`;
|
|
649
|
+
}
|
|
650
|
+
return `${q}${seg}${q}`;
|
|
651
|
+
});
|
|
652
|
+
const cArrayRegex = new RegExp(
|
|
653
|
+
`(${q}c${q}:\\[)${regexParts.join(",")}(\\])`,
|
|
647
654
|
"g"
|
|
648
655
|
);
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
656
|
+
const replacementParts = allSegments.map((seg) => {
|
|
657
|
+
if (seg.startsWith(":")) {
|
|
658
|
+
const pName = seg.slice(1);
|
|
659
|
+
return `\\"${paramPlaceholder(pName)}\\"`;
|
|
660
|
+
}
|
|
661
|
+
return `\\"${seg}\\"`;
|
|
662
|
+
});
|
|
663
|
+
const replacement = `$1${replacementParts.join(",")}$2`;
|
|
664
|
+
fallback = fallback.replace(cArrayRegex, replacement);
|
|
653
665
|
}
|
|
654
666
|
fallback = fallback.replace(
|
|
655
667
|
/8:\{\\"metadata\\":\[[\s\S]*?\],\\"error\\":null,\\"digest\\":\\"?\$undefined\\?"\}/,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as QlaraPluginConfig, b as QlaraRoute, c as QlaraManifest, M as ManifestRoute, R as RouteMatch } from './types
|
|
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
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* route definitions, each with a pattern and a metaDataGenerator function.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
18
|
+
import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
|
19
19
|
import type {
|
|
20
20
|
QlaraMetadata,
|
|
21
21
|
QlaraOpenGraph,
|
|
@@ -78,8 +78,6 @@ interface RendererResult {
|
|
|
78
78
|
html?: string;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
const FALLBACK_PLACEHOLDER = '__QLARA_FALLBACK__';
|
|
82
|
-
|
|
83
81
|
// Module-scope S3 client — reused across warm invocations (avoids recreating TCP/TLS connections)
|
|
84
82
|
const s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });
|
|
85
83
|
|
|
@@ -95,23 +93,27 @@ function deriveS3Key(uri: string): string {
|
|
|
95
93
|
}
|
|
96
94
|
|
|
97
95
|
/**
|
|
98
|
-
* Derive the fallback S3 key from a
|
|
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 ────────────────────
|
|
@@ -788,6 +790,322 @@ function extractRscFlightData(html: string): string | null {
|
|
|
788
790
|
return chunks.join('');
|
|
789
791
|
}
|
|
790
792
|
|
|
793
|
+
// ── Per-segment prefetch file generation (Next.js 16+) ───────────
|
|
794
|
+
//
|
|
795
|
+
// Next.js 16 introduced per-segment prefetch files in a subdirectory
|
|
796
|
+
// per page (e.g. product/1/__next._tree.txt). The renderer must generate
|
|
797
|
+
// these for renderer-created pages so they match build-time pages.
|
|
798
|
+
//
|
|
799
|
+
// Approach: discover segment files from a build-time reference page on S3,
|
|
800
|
+
// classify each file, and copy/patch as needed. If no reference page exists
|
|
801
|
+
// (Next.js 15 or no build-time pages), segment generation is skipped.
|
|
802
|
+
|
|
803
|
+
/** Cache reference segment directory per route prefix across warm invocations */
|
|
804
|
+
const referencePageCache = new Map<string, string | null>();
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Find a build-time page's segment directory in S3 to use as a template.
|
|
808
|
+
* Returns the S3 key prefix (e.g. 'product/1/') or null.
|
|
809
|
+
*/
|
|
810
|
+
async function findReferenceSegmentDir(bucket: string, routePrefix: string): Promise<string | null> {
|
|
811
|
+
const cached = referencePageCache.get(routePrefix);
|
|
812
|
+
if (cached !== undefined) return cached;
|
|
813
|
+
|
|
814
|
+
try {
|
|
815
|
+
const response = await s3.send(new ListObjectsV2Command({
|
|
816
|
+
Bucket: bucket,
|
|
817
|
+
Prefix: `${routePrefix}/`,
|
|
818
|
+
MaxKeys: 200,
|
|
819
|
+
}));
|
|
820
|
+
|
|
821
|
+
for (const obj of response.Contents || []) {
|
|
822
|
+
const key = obj.Key || '';
|
|
823
|
+
if (key.endsWith('/__next._tree.txt')) {
|
|
824
|
+
const dir = key.slice(0, key.length - '__next._tree.txt'.length);
|
|
825
|
+
referencePageCache.set(routePrefix, dir);
|
|
826
|
+
return dir;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
} catch {
|
|
830
|
+
// S3 error — skip segment generation
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
referencePageCache.set(routePrefix, null);
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
type SegmentFileType = 'shared' | 'tree' | 'head' | 'full' | 'page';
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Classify a segment file by its name.
|
|
841
|
+
*/
|
|
842
|
+
function classifySegmentFile(name: string): SegmentFileType {
|
|
843
|
+
if (name === '__next._tree.txt') return 'tree';
|
|
844
|
+
if (name === '__next._head.txt') return 'head';
|
|
845
|
+
if (name === '__next._full.txt') return 'full';
|
|
846
|
+
if (name.includes('__PAGE__')) return 'page';
|
|
847
|
+
return 'shared';
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* List all segment file names in a reference directory.
|
|
852
|
+
*/
|
|
853
|
+
async function listReferenceSegmentFiles(bucket: string, refDir: string): Promise<string[]> {
|
|
854
|
+
try {
|
|
855
|
+
const response = await s3.send(new ListObjectsV2Command({
|
|
856
|
+
Bucket: bucket,
|
|
857
|
+
Prefix: `${refDir}__next.`,
|
|
858
|
+
MaxKeys: 50,
|
|
859
|
+
}));
|
|
860
|
+
|
|
861
|
+
return (response.Contents || [])
|
|
862
|
+
.map(obj => obj.Key || '')
|
|
863
|
+
.filter(key => key.endsWith('.txt'))
|
|
864
|
+
.map(key => key.slice(refDir.length));
|
|
865
|
+
} catch {
|
|
866
|
+
return [];
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Patch the _tree.txt segment: replace dynamic paramKey values for all params.
|
|
872
|
+
* Each dynamic segment has "name":"<paramName>","paramType":"d","paramKey":"<value>".
|
|
873
|
+
* We match on the param name to replace the correct paramKey for each param.
|
|
874
|
+
*/
|
|
875
|
+
function patchTreeSegment(template: string, params: Record<string, string>): string {
|
|
876
|
+
let result = template;
|
|
877
|
+
for (const [name, value] of Object.entries(params)) {
|
|
878
|
+
result = result.replace(
|
|
879
|
+
new RegExp(`"name":"${name}","paramType":"d","paramKey":"[^"]*"`),
|
|
880
|
+
`"name":"${name}","paramType":"d","paramKey":"${value}"`
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
return result;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Patch the __PAGE__.txt segment: replace param values in the component props.
|
|
888
|
+
*/
|
|
889
|
+
function patchPageSegment(template: string, params: Record<string, string>): string {
|
|
890
|
+
let result = template;
|
|
891
|
+
for (const [key, value] of Object.entries(params)) {
|
|
892
|
+
// RSC wire format: ["$","$L2",null,{"id":"OLD"}]
|
|
893
|
+
result = result.replace(
|
|
894
|
+
new RegExp(`"${key}":"[^"]*"`),
|
|
895
|
+
`"${key}":"${value}"`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
return result;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Generate the _head.txt segment from a reference template and new metadata.
|
|
903
|
+
* Extracts preamble (module declarations) and buildId from the reference,
|
|
904
|
+
* rebuilds line 0 with new metadata.
|
|
905
|
+
*/
|
|
906
|
+
function generateHeadSegment(template: string, metadata: QlaraMetadata): string {
|
|
907
|
+
// Split into lines — preamble is everything before the line starting with '0:'
|
|
908
|
+
const lines = template.split('\n');
|
|
909
|
+
const preambleLines: string[] = [];
|
|
910
|
+
let line0 = '';
|
|
911
|
+
|
|
912
|
+
for (const line of lines) {
|
|
913
|
+
if (line.startsWith('0:')) {
|
|
914
|
+
line0 = line;
|
|
915
|
+
} else if (line.trim()) {
|
|
916
|
+
preambleLines.push(line);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Extract buildId from reference
|
|
921
|
+
const buildIdMatch = line0.match(/"buildId":"([^"]+)"/);
|
|
922
|
+
const buildId = buildIdMatch ? buildIdMatch[1] : '';
|
|
923
|
+
|
|
924
|
+
// Extract the viewport/charset meta tags from the reference (preserve them)
|
|
925
|
+
// These are inside the rsc at children[1] (the $L2 viewport boundary)
|
|
926
|
+
// We keep the structure identical but replace the metadata children
|
|
927
|
+
const viewportMatch = line0.match(/"children":\[(\["\\?\$","meta"[^\]]*\](?:,\["\\?\$","meta"[^\]]*\])*)\]/);
|
|
928
|
+
const viewportTags = viewportMatch ? viewportMatch[1] : '["\$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]';
|
|
929
|
+
|
|
930
|
+
// Build metadata RSC children array (standard JSON, not HTML-escaped)
|
|
931
|
+
const metaChildren: string[] = [];
|
|
932
|
+
let idx = 0;
|
|
933
|
+
|
|
934
|
+
// Title
|
|
935
|
+
metaChildren.push(`["$","title","${idx++}",{"children":"${escapeJson(metadata.title)}"}]`);
|
|
936
|
+
|
|
937
|
+
// Description
|
|
938
|
+
if (metadata.description) {
|
|
939
|
+
metaChildren.push(`["$","meta","${idx++}",{"name":"description","content":"${escapeJson(metadata.description)}"}]`);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Open Graph
|
|
943
|
+
if (metadata.openGraph) {
|
|
944
|
+
const og = metadata.openGraph;
|
|
945
|
+
if (og.title) metaChildren.push(`["$","meta","${idx++}",{"property":"og:title","content":"${escapeJson(og.title)}"}]`);
|
|
946
|
+
if (og.description) metaChildren.push(`["$","meta","${idx++}",{"property":"og:description","content":"${escapeJson(og.description)}"}]`);
|
|
947
|
+
if (og.url) metaChildren.push(`["$","meta","${idx++}",{"property":"og:url","content":"${escapeJson(og.url)}"}]`);
|
|
948
|
+
if (og.siteName) metaChildren.push(`["$","meta","${idx++}",{"property":"og:site_name","content":"${escapeJson(og.siteName)}"}]`);
|
|
949
|
+
if (og.type) metaChildren.push(`["$","meta","${idx++}",{"property":"og:type","content":"${escapeJson(og.type)}"}]`);
|
|
950
|
+
for (const img of toArray(og.images)) {
|
|
951
|
+
if (typeof img === 'string') {
|
|
952
|
+
metaChildren.push(`["$","meta","${idx++}",{"property":"og:image","content":"${escapeJson(img)}"}]`);
|
|
953
|
+
} else {
|
|
954
|
+
metaChildren.push(`["$","meta","${idx++}",{"property":"og:image","content":"${escapeJson(img.url)}"}]`);
|
|
955
|
+
if (img.alt) metaChildren.push(`["$","meta","${idx++}",{"property":"og:image:alt","content":"${escapeJson(img.alt)}"}]`);
|
|
956
|
+
if (img.width !== undefined) metaChildren.push(`["$","meta","${idx++}",{"property":"og:image:width","content":"${String(img.width)}"}]`);
|
|
957
|
+
if (img.height !== undefined) metaChildren.push(`["$","meta","${idx++}",{"property":"og:image:height","content":"${String(img.height)}"}]`);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Twitter
|
|
963
|
+
if (metadata.twitter) {
|
|
964
|
+
const tw = metadata.twitter;
|
|
965
|
+
const card = ('card' in tw && tw.card) ? tw.card : 'summary';
|
|
966
|
+
metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:card","content":"${escapeJson(card)}"}]`);
|
|
967
|
+
if (tw.title) metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:title","content":"${escapeJson(tw.title)}"}]`);
|
|
968
|
+
if (tw.description) metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:description","content":"${escapeJson(tw.description)}"}]`);
|
|
969
|
+
for (const img of toArray(tw.images)) {
|
|
970
|
+
if (typeof img === 'string') {
|
|
971
|
+
metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:image","content":"${escapeJson(img)}"}]`);
|
|
972
|
+
} else {
|
|
973
|
+
metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:image","content":"${escapeJson(img.url)}"}]`);
|
|
974
|
+
if (img.alt) metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:image:alt","content":"${escapeJson(img.alt)}"}]`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Reconstruct the _head.txt content by replacing the metadata children
|
|
980
|
+
// in the reference template's line 0 structure
|
|
981
|
+
const metaChildrenStr = metaChildren.join(',');
|
|
982
|
+
|
|
983
|
+
// The _head.txt line 0 structure (from analysis):
|
|
984
|
+
// 0:{"buildId":"...","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[viewport tags]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[METADATA HERE]}]}]}],null]}],"loading":null,"isPartial":false}
|
|
985
|
+
// Replace the "Next.Metadata" children array
|
|
986
|
+
const newLine0 = line0.replace(
|
|
987
|
+
/("name":"Next\.Metadata","children":\[)[\s\S]*?(\]\})/,
|
|
988
|
+
`$1${metaChildrenStr}$2`
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
return preambleLines.join('\n') + '\n' + newLine0 + '\n';
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Generate per-segment prefetch files for a renderer-created page (Next.js 16+).
|
|
996
|
+
* Reads segment files from a build-time reference page, patches page-specific data,
|
|
997
|
+
* and uploads to the new page's subdirectory.
|
|
998
|
+
*
|
|
999
|
+
* Skips gracefully if no reference page exists (Next.js 15 or first deploy).
|
|
1000
|
+
*/
|
|
1001
|
+
async function generateSegmentFiles(
|
|
1002
|
+
bucket: string,
|
|
1003
|
+
uri: string,
|
|
1004
|
+
params: Record<string, string>,
|
|
1005
|
+
rscData: string | null,
|
|
1006
|
+
metadata: QlaraMetadata | null,
|
|
1007
|
+
): Promise<void> {
|
|
1008
|
+
const cleanUri = uri.replace(/^\//, '').replace(/\/$/, '');
|
|
1009
|
+
const parts = cleanUri.split('/');
|
|
1010
|
+
const routePrefix = parts.slice(0, -1).join('/'); // 'product'
|
|
1011
|
+
const segmentDir = `${cleanUri}/`; // 'product/42/'
|
|
1012
|
+
|
|
1013
|
+
// Find a build-time page with segment files to use as reference
|
|
1014
|
+
const refDir = await findReferenceSegmentDir(bucket, routePrefix);
|
|
1015
|
+
if (!refDir) return; // No segment files on S3 — Next.js 15 or no build-time pages
|
|
1016
|
+
|
|
1017
|
+
// List all segment files in the reference directory
|
|
1018
|
+
const fileNames = await listReferenceSegmentFiles(bucket, refDir);
|
|
1019
|
+
if (fileNames.length === 0) return;
|
|
1020
|
+
|
|
1021
|
+
// Read all reference files we need (skip _full — we use rscData directly)
|
|
1022
|
+
const filesToRead = fileNames.filter(name => classifySegmentFile(name) !== 'full');
|
|
1023
|
+
const readResults = await Promise.allSettled(
|
|
1024
|
+
filesToRead.map(async (name) => {
|
|
1025
|
+
const result = await s3.send(new GetObjectCommand({
|
|
1026
|
+
Bucket: bucket,
|
|
1027
|
+
Key: `${refDir}${name}`,
|
|
1028
|
+
}));
|
|
1029
|
+
return {
|
|
1030
|
+
name,
|
|
1031
|
+
content: await result.Body?.transformToString('utf-8') || '',
|
|
1032
|
+
};
|
|
1033
|
+
})
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
const refMap = new Map<string, string>();
|
|
1037
|
+
for (const result of readResults) {
|
|
1038
|
+
if (result.status === 'fulfilled' && result.value.content) {
|
|
1039
|
+
refMap.set(result.value.name, result.value.content);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Generate each segment file
|
|
1044
|
+
const uploads: Promise<unknown>[] = [];
|
|
1045
|
+
const cacheControl = `public, max-age=0, s-maxage=${__QLARA_CACHE_TTL__}, stale-while-revalidate=60`;
|
|
1046
|
+
|
|
1047
|
+
for (const name of fileNames) {
|
|
1048
|
+
let content: string | null = null;
|
|
1049
|
+
const type = classifySegmentFile(name);
|
|
1050
|
+
|
|
1051
|
+
switch (type) {
|
|
1052
|
+
case 'shared':
|
|
1053
|
+
content = refMap.get(name) || null;
|
|
1054
|
+
break;
|
|
1055
|
+
case 'tree': {
|
|
1056
|
+
const template = refMap.get(name);
|
|
1057
|
+
if (template) {
|
|
1058
|
+
content = patchTreeSegment(template, params);
|
|
1059
|
+
}
|
|
1060
|
+
break;
|
|
1061
|
+
}
|
|
1062
|
+
case 'head': {
|
|
1063
|
+
const template = refMap.get(name);
|
|
1064
|
+
if (template && metadata) {
|
|
1065
|
+
content = generateHeadSegment(template, metadata);
|
|
1066
|
+
}
|
|
1067
|
+
break;
|
|
1068
|
+
}
|
|
1069
|
+
case 'full':
|
|
1070
|
+
content = rscData;
|
|
1071
|
+
break;
|
|
1072
|
+
case 'page': {
|
|
1073
|
+
const template = refMap.get(name);
|
|
1074
|
+
if (template) {
|
|
1075
|
+
content = patchPageSegment(template, params);
|
|
1076
|
+
}
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (content) {
|
|
1082
|
+
uploads.push(
|
|
1083
|
+
s3.send(new PutObjectCommand({
|
|
1084
|
+
Bucket: bucket,
|
|
1085
|
+
Key: `${segmentDir}${name}`,
|
|
1086
|
+
Body: content,
|
|
1087
|
+
ContentType: 'text/plain; charset=utf-8',
|
|
1088
|
+
CacheControl: cacheControl,
|
|
1089
|
+
}))
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
await Promise.all(uploads);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Escape a string for use inside a JSON string value.
|
|
1099
|
+
*/
|
|
1100
|
+
function escapeJson(str: string): string {
|
|
1101
|
+
return str
|
|
1102
|
+
.replace(/\\/g, '\\\\')
|
|
1103
|
+
.replace(/"/g, '\\"')
|
|
1104
|
+
.replace(/\n/g, '\\n')
|
|
1105
|
+
.replace(/\r/g, '\\r')
|
|
1106
|
+
.replace(/\t/g, '\\t');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
791
1109
|
export async function handler(event: RendererEvent & { warmup?: boolean }): Promise<RendererResult> {
|
|
792
1110
|
// Warmup invocation — just initialize the runtime and return
|
|
793
1111
|
if (event.warmup) {
|
|
@@ -797,9 +1115,22 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
|
|
|
797
1115
|
const { uri, bucket, routePattern, params } = event;
|
|
798
1116
|
|
|
799
1117
|
try {
|
|
800
|
-
// 0.
|
|
1118
|
+
// 0. Find route definition and run validation (if defined)
|
|
1119
|
+
const routeDef = routes?.find((r: { route: string }) => r.route === routePattern);
|
|
1120
|
+
|
|
1121
|
+
if (routeDef?.validate) {
|
|
1122
|
+
const isValid = await routeDef.validate(params);
|
|
1123
|
+
if (!isValid) {
|
|
1124
|
+
return {
|
|
1125
|
+
statusCode: 404,
|
|
1126
|
+
body: JSON.stringify({ error: `Validation failed for ${uri}`, params }),
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// 1. Check if already rendered + read fallback in parallel
|
|
801
1132
|
const s3Key = deriveS3Key(uri);
|
|
802
|
-
const fallbackKey = deriveFallbackKey(
|
|
1133
|
+
const fallbackKey = deriveFallbackKey(routePattern);
|
|
803
1134
|
|
|
804
1135
|
const [existingResult, fallbackResult] = await Promise.allSettled([
|
|
805
1136
|
s3.send(new GetObjectCommand({ Bucket: bucket, Key: s3Key })),
|
|
@@ -836,18 +1167,18 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
|
|
|
836
1167
|
};
|
|
837
1168
|
}
|
|
838
1169
|
|
|
839
|
-
// 2. Patch the fallback with
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
new RegExp(
|
|
843
|
-
|
|
844
|
-
);
|
|
1170
|
+
// 2. Patch the fallback with actual param values (per-param placeholders)
|
|
1171
|
+
let html = fallbackHtml;
|
|
1172
|
+
for (const [name, value] of Object.entries(params)) {
|
|
1173
|
+
html = html.replace(new RegExp(paramPlaceholder(name), 'g'), value);
|
|
1174
|
+
}
|
|
845
1175
|
|
|
846
|
-
// 3. Call
|
|
847
|
-
const
|
|
1176
|
+
// 3. Call generateMetadata (or deprecated metaDataGenerator) to fetch metadata
|
|
1177
|
+
const metadataFn = routeDef?.generateMetadata || routeDef?.metaDataGenerator;
|
|
1178
|
+
let metadata: QlaraMetadata | null = null;
|
|
848
1179
|
|
|
849
|
-
if (
|
|
850
|
-
|
|
1180
|
+
if (metadataFn) {
|
|
1181
|
+
metadata = await metadataFn(params);
|
|
851
1182
|
if (metadata) {
|
|
852
1183
|
// 4. Patch the HTML with real metadata
|
|
853
1184
|
html = patchMetadata(html, metadata);
|
|
@@ -866,9 +1197,8 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
|
|
|
866
1197
|
);
|
|
867
1198
|
|
|
868
1199
|
// 6. Framework-specific post-render uploads
|
|
869
|
-
// Next.js: extract RSC flight data
|
|
870
|
-
//
|
|
871
|
-
// Without this, client-side nav falls back to a full page reload (slow).
|
|
1200
|
+
// Next.js: extract RSC flight data, upload .txt for client-side navigation,
|
|
1201
|
+
// and generate per-segment prefetch files (Next.js 16+).
|
|
872
1202
|
if (__QLARA_FRAMEWORK__ === 'next') {
|
|
873
1203
|
const rscData = extractRscFlightData(html);
|
|
874
1204
|
if (rscData) {
|
|
@@ -883,6 +1213,11 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
|
|
|
883
1213
|
})
|
|
884
1214
|
);
|
|
885
1215
|
}
|
|
1216
|
+
|
|
1217
|
+
// 7. Generate per-segment prefetch files (Next.js 16+ Segment Cache)
|
|
1218
|
+
// Reads templates from a build-time reference page, patches page-specific data.
|
|
1219
|
+
// Skips automatically for Next.js 15 (no segment files on S3).
|
|
1220
|
+
await generateSegmentFiles(bucket, uri, params, rscData, metadata);
|
|
886
1221
|
}
|
|
887
1222
|
|
|
888
1223
|
return {
|
package/src/types.ts
CHANGED
|
@@ -456,33 +456,63 @@ export interface QlaraMetadata {
|
|
|
456
456
|
}
|
|
457
457
|
|
|
458
458
|
/**
|
|
459
|
-
* Function that
|
|
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
|
|