@sitecore-content-sdk/nextjs 2.0.2 → 2.1.0-canary.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,13 +5,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.PersonalizeProxy = void 0;
7
7
  const personalize_1 = require("@sitecore-content-sdk/content/personalize");
8
- const proxy_1 = require("./proxy");
8
+ const internal_1 = require("@sitecore-content-sdk/analytics-core/internal");
9
9
  const core_1 = require("@sitecore-content-sdk/core");
10
10
  const personalize_2 = require("@sitecore-content-sdk/personalize");
11
- const debug_1 = __importDefault(require("../debug"));
12
11
  const analytics_core_1 = require("@sitecore-content-sdk/analytics-core");
13
- const analytics_adapter_1 = require("../initialization/proxy/analytics-adapter");
14
12
  const personalize_3 = require("@sitecore-content-sdk/personalize");
13
+ const analytics_adapter_1 = require("../initialization/proxy/analytics-adapter");
14
+ const proxy_1 = require("./proxy");
15
+ const debug_1 = __importDefault(require("../debug"));
15
16
  const personalize_adapter_1 = require("../initialization/proxy/personalize-adapter");
16
17
  /**
17
18
  * Proxy / handler to support Sitecore Personalize
@@ -26,11 +27,13 @@ class PersonalizeProxy extends proxy_1.ProxyBase {
26
27
  super(config);
27
28
  this.config = config;
28
29
  this.handle = async (req, res) => {
30
+ var _a, _b;
29
31
  if (!this.config.enabled) {
30
32
  debug_1.default.personalize('skipped (personalize proxy is disabled globally)');
31
33
  return res;
32
34
  }
33
35
  try {
36
+ const skipForBot = (_a = this.config.skipForBot) !== null && _a !== void 0 ? _a : true;
34
37
  const pathname = req.nextUrl.pathname;
35
38
  const language = this.getLanguage(req, res);
36
39
  const hostname = this.getHostHeader(req) || this.defaultHostname;
@@ -52,6 +55,10 @@ class PersonalizeProxy extends proxy_1.ProxyBase {
52
55
  debug_1.default.personalize('skipped (%s)', res.redirected ? 'redirected' : 'preview');
53
56
  return res;
54
57
  }
58
+ if (skipForBot && ((_b = req.cookies.get(internal_1.BOT_DETECTION_COOKIE)) === null || _b === void 0 ? void 0 : _b.value)) {
59
+ debug_1.default.personalize('skipped (bot request)');
60
+ return res;
61
+ }
55
62
  const site = this.getSite(req, res);
56
63
  // Get personalization info from Experience Edge
57
64
  // personalizeService is guaranteed to be non-null here because disabled() check passed
@@ -17,6 +17,37 @@ exports.LOCALE_HEADER_NAME = 'x-sc-locale';
17
17
  class ProxyHandler {
18
18
  }
19
19
  exports.ProxyHandler = ProxyHandler;
20
+ /**
21
+ * Hostname from a `Host` or `x-forwarded-host` value, without port.
22
+ * - `[::1]:3000` → `::1`
23
+ * - `127.0.0.1:3000` → `127.0.0.1`
24
+ * - `example.com:443` → `example.com`
25
+ * - `::1` → `::1` (does not treat `:1` as a port)
26
+ * @param {string} host - Raw header value
27
+ */
28
+ function getHostnameFromHostHeader(host) {
29
+ const trimmed = host.trim();
30
+ // Bracketed IPv6: "[...]:port" or "[...]"
31
+ if (trimmed.startsWith('[')) {
32
+ const end = trimmed.indexOf(']');
33
+ if (end !== -1) {
34
+ return trimmed.slice(1, end).toLowerCase();
35
+ }
36
+ }
37
+ // Unbracketed IPv6 (e.g. ::1, 2001:db8::1) — never strip on last ":digits"
38
+ if (trimmed.includes('::')) {
39
+ return trimmed.toLowerCase();
40
+ }
41
+ // IPv4 or DNS name with ":port" (port = decimal digits only)
42
+ const lastColon = trimmed.lastIndexOf(':');
43
+ if (lastColon > 0) {
44
+ const after = trimmed.slice(lastColon + 1);
45
+ if (/^\d+$/.test(after)) {
46
+ return trimmed.slice(0, lastColon).toLowerCase();
47
+ }
48
+ }
49
+ return trimmed.toLowerCase();
50
+ }
20
51
  /**
21
52
  * Base proxy class with common methods
22
53
  * @public
@@ -113,8 +144,7 @@ class ProxyBase extends ProxyHandler {
113
144
  * @param {NextRequest} req request
114
145
  */
115
146
  getHostHeader(req) {
116
- var _a;
117
- return req.headers.get('x-forwarded-host') || ((_a = req.headers.get('host')) === null || _a === void 0 ? void 0 : _a.split(':')[0]);
147
+ return getHostnameFromHostHeader(req.headers.get('x-forwarded-host') || req.headers.get('host') || '');
118
148
  }
119
149
  /**
120
150
  * Get site information. If site name is stored in cookie, use it, otherwise resolve by hostname
@@ -65,7 +65,7 @@ class RedirectsProxy extends proxy_1.ProxyBase {
65
65
  }
66
66
  site = this.getSite(req, res);
67
67
  // Find the redirect from result of RedirectService
68
- const existsRedirect = await this.getExistsRedirect(req, site.name);
68
+ const existsRedirect = await this.getExistsRedirect(req, site.name, language);
69
69
  if (!existsRedirect) {
70
70
  debug_1.default.redirects('skipped (redirect does not exist)');
71
71
  return res;
@@ -202,19 +202,38 @@ class RedirectsProxy extends proxy_1.ProxyBase {
202
202
  * @returns Promise<RedirectInfo | undefined>
203
203
  * @private
204
204
  */
205
- async getExistsRedirect(req, siteName) {
205
+ async getExistsRedirect(req, siteName, requestLocale) {
206
206
  if (!this.redirectsService) {
207
207
  return undefined;
208
208
  }
209
209
  const { pathname: incomingURL, search: incomingQS = '' } = this.normalizeUrl(req.nextUrl.clone());
210
- const locale = this.getLanguage(req);
211
210
  const normalizedPath = incomingURL.replace(/\/*$/gi, '').toLowerCase();
212
211
  const redirects = await this.redirectsService.fetchRedirects(siteName);
213
- const modifyRedirects = structuredClone(redirects);
212
+ // using locale of current request (from URL, headers or otherwise), used to match versioned redirect rules
213
+ const matchedLocaleRedirect = this.matchRedirectItemRedirect(redirects, requestLocale, normalizedPath);
214
+ if (matchedLocaleRedirect) {
215
+ return matchedLocaleRedirect;
216
+ }
217
+ // locale of the url - if present in the url, used for redirect map matching
218
+ const urlLocale = req.nextUrl.locale;
219
+ return this.matchFromRedirectMapRedirect(redirects, urlLocale, incomingURL, incomingQS);
220
+ }
221
+ /**
222
+ * Matches redirect-map rules without a `locale` field against the incoming URL (static or regex patterns).
223
+ * @param {RedirectResult[]} redirects All redirects from the service (non-locale entries are filtered inside).
224
+ * @param {string} urlLocale Locale segment from the request URL (`nextUrl.locale`).
225
+ * @param {string} incomingURL Original pathname used for regex tests.
226
+ * @param {string} incomingQS Query string including leading `?` if present.
227
+ * @returns {RedirectResult | undefined} First matching redirect or undefined.
228
+ * @private
229
+ */
230
+ matchFromRedirectMapRedirect(redirects, urlLocale, incomingURL, incomingQS) {
231
+ const nonLocaleRedirects = redirects.filter((redirect) => !redirect.locale);
214
232
  let matchedQueryString;
215
- const localePath = `/${locale.toLowerCase()}${normalizedPath}`;
216
- return modifyRedirects.length
217
- ? modifyRedirects.find((redirect) => {
233
+ const normalizedPath = incomingURL.replace(/\/*$/gi, '').toLowerCase();
234
+ const localePath = `/${urlLocale.toLowerCase()}${normalizedPath}`;
235
+ return nonLocaleRedirects.length
236
+ ? nonLocaleRedirects.find((redirect) => {
218
237
  // process static URL (non-regex) rules
219
238
  if ((0, tools_1.isRegexOrUrl)(redirect.pattern) === 'url') {
220
239
  const urlArray = redirect.pattern.endsWith('/')
@@ -236,14 +255,14 @@ class RedirectsProxy extends proxy_1.ProxyBase {
236
255
  // process regex rules
237
256
  // Modify the redirect pattern to ignore the language prefix in the path
238
257
  // And escapes non-special "?" characters in a string or regex.
239
- redirect.pattern = (0, tools_1.escapeNonSpecialQuestionMarks)('^' + redirect.pattern.replace(new RegExp(`^[^]?/${locale}/`, 'gi'), '') // ensure function thinks input is regex
258
+ redirect.pattern = (0, tools_1.escapeNonSpecialQuestionMarks)('^' + redirect.pattern.replace(new RegExp(`^[^]?/${urlLocale}/`, 'gi'), '') // ensure function thinks input is regex
240
259
  );
241
260
  // Prepare the redirect pattern as a regular expression, making it more flexible for matching URLs
242
261
  redirect.pattern = `/^\/${redirect.pattern
243
262
  .replace(/^\/|\/$/g, '') // Removes leading and trailing slashes
244
263
  .replace(/^\^\/|\/\$$/g, '') // Removes unnecessary start (^) and end ($) anchors
245
264
  .replace(/^\^|\$$/g, '') // Further cleans up anchors
246
- .replace(/\$\/gi$/g, '')}[\/]?$/i`; // Ensures the pattern allows an optional trailing slash
265
+ .replace(/\$\/g$/g, '')}[\/]?$/i`; // Ensures the pattern allows an optional trailing slash
247
266
  // Redirect pattern matches the full incoming URL with query string present
248
267
  matchedQueryString = [
249
268
  (0, regex_parser_1.default)(redirect.pattern).test(`/${localePath}${incomingQS}`),
@@ -253,9 +272,28 @@ class RedirectsProxy extends proxy_1.ProxyBase {
253
272
  : undefined;
254
273
  // Save the matched query string (if found) into the redirect object
255
274
  redirect.matchedQueryString = matchedQueryString || '';
256
- return (!!((0, regex_parser_1.default)(redirect.pattern).test(`/${req.nextUrl.locale}${incomingURL}`) ||
275
+ return (!!((0, regex_parser_1.default)(redirect.pattern).test(`/${urlLocale}${incomingURL}`) ||
257
276
  (0, regex_parser_1.default)(redirect.pattern).test(incomingURL) ||
258
- matchedQueryString) && (redirect.locale ? redirect.locale.toLowerCase() === locale.toLowerCase() : true));
277
+ matchedQueryString) &&
278
+ (redirect.locale ? redirect.locale.toLowerCase() === urlLocale.toLowerCase() : true));
279
+ })
280
+ : undefined;
281
+ }
282
+ /**
283
+ * Processes redirect rules from redirect items (language-versioned)
284
+ * @param {RedirectResult[]} redirects redirect entries from Edge
285
+ * @param {string} locale current request locale
286
+ * @param {string} currentPath current request path
287
+ * @returns {RedirectResult | undefined} matched redirect item redirect result or undefined
288
+ * @private
289
+ */
290
+ matchRedirectItemRedirect(redirects, locale, currentPath) {
291
+ const nonLocalePath = currentPath.replace(new RegExp(`^\/?${locale}\/`, 'i'), '/');
292
+ return redirects.length
293
+ ? redirects.find((redirect) => {
294
+ const patternPath = redirect.pattern.replace(/\/*$/g, '').toLowerCase();
295
+ // locale rules are easy and nice
296
+ return redirect.locale === locale && patternPath === nonLocalePath;
259
297
  })
260
298
  : undefined;
261
299
  }
@@ -13,6 +13,7 @@ var __rest = (this && this.__rest) || function (s, e) {
13
13
  import React, { forwardRef } from 'react';
14
14
  import NextLink from 'next/link';
15
15
  import { Link as ReactLink, } from '@sitecore-content-sdk/react';
16
+ import { useRouter as usePageRouter } from 'next/compat/router';
16
17
  /**
17
18
  * The list of NextLink props to be supported by the Link component.
18
19
  */
@@ -34,6 +35,7 @@ const FILE_EXTENSION_MATCHER = /^\/.*\.\w+$/;
34
35
  * @public
35
36
  */
36
37
  export const Link = forwardRef((props, ref) => {
38
+ const pageRouter = usePageRouter();
37
39
  const { field, editable = true, children, internalLinkMatcher = /^\//g, showLinkTextWithChildrenPresent } = props, rest = __rest(props, ["field", "editable", "children", "internalLinkMatcher", "showLinkTextWithChildrenPresent"]);
38
40
  if (!field || (!field.value && !field.href && !field.metadata)) {
39
41
  return null;
@@ -48,10 +50,13 @@ export const Link = forwardRef((props, ref) => {
48
50
  const isFileUrl = FILE_EXTENSION_MATCHER.test(href);
49
51
  // determine if a link is a route or not. File extensions are not routes and should not be pre-fetched.
50
52
  if (isMatching && !isFileUrl) {
51
- return (React.createElement(NextLink, Object.assign({ href: { pathname: href, query: querystring, hash: anchor }, key: "link", title: value.title, target: value.target, className: value.class }, rest, { locale: false, ref: ref }, (process.env.TEST
53
+ return (React.createElement(NextLink, Object.assign({ href: { pathname: href, query: querystring, hash: anchor }, key: "link", title: value.title, target: value.target, className: value.class }, rest, {
54
+ // locale prop is supported only in Pages Router
55
+ locale: pageRouter ? false : undefined, ref: ref }, (process.env.TEST
52
56
  ? {
53
57
  'data-nextjs-link': true,
54
58
  'data-nextjs-prefetch': props.prefetch,
59
+ 'data-nextjs-locale': pageRouter ? false : undefined,
55
60
  }
56
61
  : {})),
57
62
  text,
@@ -1,5 +1,4 @@
1
- import { COOKIE_NAME_PREFIX, fetchClientIdFromEdgeProxy, getDefaultCookieAttributes, } from '@sitecore-content-sdk/analytics-core/internal';
2
- import { getAnalyticsPlugin, } from '@sitecore-content-sdk/analytics-core/internal';
1
+ import { COOKIE_NAME_PREFIX, fetchClientIdFromEdgeProxy, getBotCookieServerSide, getDefaultCookieAttributes, getAnalyticsPlugin, isBot, } from '@sitecore-content-sdk/analytics-core/internal';
3
2
  import { getCoreContext } from '@sitecore-content-sdk/core';
4
3
  /**
5
4
  * Creates a proxy-based analytics adapter that reads and writes the visitor ID
@@ -13,6 +12,10 @@ import { getCoreContext } from '@sitecore-content-sdk/core';
13
12
  export function analyticsProxyAdapter(request, response) {
14
13
  return {
15
14
  type: 'proxy',
15
+ isBot: () => {
16
+ const botCookie = getBotCookieServerSide(request.cookies.toString());
17
+ return !!botCookie || isBot(request.headers.get('user-agent'));
18
+ },
16
19
  getClientId: () => {
17
20
  return getClientId(request);
18
21
  },
@@ -0,0 +1,120 @@
1
+ import { initContentSdk } from '@sitecore-content-sdk/core';
2
+ import { analyticsPlugin } from '@sitecore-content-sdk/analytics-core';
3
+ import { eventsPlugin, botPageView } from '@sitecore-content-sdk/events';
4
+ import { isBot, BOT_DETECTION_COOKIE } from '@sitecore-content-sdk/analytics-core/internal';
5
+ import { ProxyBase } from './proxy';
6
+ import debug from '../debug';
7
+ import { analyticsProxyAdapter } from '../initialization/proxy/analytics-adapter';
8
+ /**
9
+ * Next.js proxy that runs bot detection once per request and sets the bot cookie.
10
+ * Run first in the proxy chain to ensure that the bot cookie is set before other proxies run.
11
+ * @public
12
+ */
13
+ export class BotTrackingProxy extends ProxyBase {
14
+ constructor(config) {
15
+ super(config);
16
+ this.config = config;
17
+ this.handle = async (req, res) => {
18
+ try {
19
+ const isDisabled = (this.config.skip && this.config.skip(req, res)) || false;
20
+ debug.common('bot tracking proxy start: %o', {
21
+ pathname: req.nextUrl.pathname,
22
+ headers: this.extractDebugHeaders(req.headers),
23
+ });
24
+ if (isDisabled) {
25
+ debug.common('bot tracking proxy skipped (disabled)');
26
+ return res;
27
+ }
28
+ if (this.isPreview(req)) {
29
+ debug.common('bot tracking proxy skipped (preview)');
30
+ return res;
31
+ }
32
+ if (this.shouldSkipForLocalEnvironment(req)) {
33
+ debug.common('bot tracking proxy skipped (local environment)');
34
+ return res;
35
+ }
36
+ const userAgent = req.headers.get('user-agent');
37
+ if (!userAgent) {
38
+ debug.common('bot tracking proxy skipped (no user-agent)');
39
+ return res;
40
+ }
41
+ if (!isBot(userAgent)) {
42
+ debug.common('bot tracking proxy skipped (not a bot)');
43
+ return res;
44
+ }
45
+ if (this.isPrefetch(req)) {
46
+ debug.common('bot tracking proxy skipped (prefetch)');
47
+ return res;
48
+ }
49
+ const site = this.getSite(req, res);
50
+ const language = this.getLanguage(req, res);
51
+ const botTracking = async () => {
52
+ await initContentSdk({
53
+ config: {
54
+ contextId: this.config.contextId,
55
+ edgeUrl: this.config.edgeUrl,
56
+ siteName: site.name,
57
+ },
58
+ plugins: [
59
+ analyticsPlugin({
60
+ options: {
61
+ enableCookie: false,
62
+ },
63
+ adapter: analyticsProxyAdapter(req, res),
64
+ }),
65
+ eventsPlugin(),
66
+ ],
67
+ });
68
+ await botPageView({
69
+ page: req.nextUrl.pathname,
70
+ language: language,
71
+ userAgent,
72
+ });
73
+ };
74
+ res.cookies.set(BOT_DETECTION_COOKIE, '1', {
75
+ secure: true,
76
+ sameSite: 'lax',
77
+ path: '/',
78
+ });
79
+ debug.common('bot tracking proxy (visitor is a bot)');
80
+ if (this.config.fetchEvent) {
81
+ this.config.fetchEvent.waitUntil(botTracking());
82
+ }
83
+ else {
84
+ await botTracking();
85
+ }
86
+ debug.common('bot tracking proxy end: %o', {
87
+ pathname: req.nextUrl.pathname,
88
+ cookies: res.cookies,
89
+ });
90
+ return res;
91
+ }
92
+ catch (error) {
93
+ debug.common('bot tracking proxy error: %o', error);
94
+ return res;
95
+ }
96
+ };
97
+ }
98
+ /**
99
+ * @param {NextRequest} req - Incoming request
100
+ * @returns True when bot tracking should be skipped for a local / dev environment.
101
+ * @internal
102
+ */
103
+ shouldSkipForLocalEnvironment(req) {
104
+ // Allow bot tracking in local environment for development purposes
105
+ if (process.env.SITECORE_ENABLE_BOT_TRACKING === 'true') {
106
+ return false;
107
+ }
108
+ if (process.env.NODE_ENV === 'development') {
109
+ return true;
110
+ }
111
+ const hostName = (this.getHostHeader(req) ||
112
+ req.nextUrl.hostname ||
113
+ this.defaultHostname ||
114
+ '').toLowerCase();
115
+ return (hostName === 'localhost' ||
116
+ hostName === '127.0.0.1' ||
117
+ hostName === '::1' ||
118
+ hostName === '[::1]');
119
+ }
120
+ }
@@ -5,5 +5,6 @@ export { MultisiteProxy } from './multisite-proxy';
5
5
  export { AppRouterMultisiteProxy } from './app-router-multisite-proxy';
6
6
  export { LocaleProxy } from './locale-proxy';
7
7
  export { PersonalizeService, } from '@sitecore-content-sdk/content/personalize';
8
+ export { BotTrackingProxy } from './bot-tracking-proxy';
8
9
  export { RedirectsService, REDIRECT_TYPE_301, REDIRECT_TYPE_302, REDIRECT_TYPE_SERVER_TRANSFER, } from '@sitecore-content-sdk/content/site';
9
10
  export { default as debug } from '../debug';
@@ -1,11 +1,12 @@
1
1
  import { PersonalizeService, getPersonalizedRewrite, CdpHelper, DEFAULT_VARIANT, } from '@sitecore-content-sdk/content/personalize';
2
- import { ProxyBase, REWRITE_HEADER_NAME } from './proxy';
2
+ import { BOT_DETECTION_COOKIE } from '@sitecore-content-sdk/analytics-core/internal';
3
3
  import { initContentSdk } from '@sitecore-content-sdk/core';
4
4
  import { personalize } from '@sitecore-content-sdk/personalize';
5
- import debug from '../debug';
6
5
  import { analyticsPlugin } from '@sitecore-content-sdk/analytics-core';
7
- import { analyticsProxyAdapter } from '../initialization/proxy/analytics-adapter';
8
6
  import { personalizeServerPlugin } from '@sitecore-content-sdk/personalize';
7
+ import { analyticsProxyAdapter } from '../initialization/proxy/analytics-adapter';
8
+ import { ProxyBase, REWRITE_HEADER_NAME } from './proxy';
9
+ import debug from '../debug';
9
10
  import { personalizeProxyAdapter } from '../initialization/proxy/personalize-adapter';
10
11
  /**
11
12
  * Proxy / handler to support Sitecore Personalize
@@ -20,11 +21,13 @@ export class PersonalizeProxy extends ProxyBase {
20
21
  super(config);
21
22
  this.config = config;
22
23
  this.handle = async (req, res) => {
24
+ var _a, _b;
23
25
  if (!this.config.enabled) {
24
26
  debug.personalize('skipped (personalize proxy is disabled globally)');
25
27
  return res;
26
28
  }
27
29
  try {
30
+ const skipForBot = (_a = this.config.skipForBot) !== null && _a !== void 0 ? _a : true;
28
31
  const pathname = req.nextUrl.pathname;
29
32
  const language = this.getLanguage(req, res);
30
33
  const hostname = this.getHostHeader(req) || this.defaultHostname;
@@ -46,6 +49,10 @@ export class PersonalizeProxy extends ProxyBase {
46
49
  debug.personalize('skipped (%s)', res.redirected ? 'redirected' : 'preview');
47
50
  return res;
48
51
  }
52
+ if (skipForBot && ((_b = req.cookies.get(BOT_DETECTION_COOKIE)) === null || _b === void 0 ? void 0 : _b.value)) {
53
+ debug.personalize('skipped (bot request)');
54
+ return res;
55
+ }
49
56
  const site = this.getSite(req, res);
50
57
  // Get personalization info from Experience Edge
51
58
  // personalizeService is guaranteed to be non-null here because disabled() check passed
@@ -10,6 +10,37 @@ export const LOCALE_HEADER_NAME = 'x-sc-locale';
10
10
  */
11
11
  export class ProxyHandler {
12
12
  }
13
+ /**
14
+ * Hostname from a `Host` or `x-forwarded-host` value, without port.
15
+ * - `[::1]:3000` → `::1`
16
+ * - `127.0.0.1:3000` → `127.0.0.1`
17
+ * - `example.com:443` → `example.com`
18
+ * - `::1` → `::1` (does not treat `:1` as a port)
19
+ * @param {string} host - Raw header value
20
+ */
21
+ function getHostnameFromHostHeader(host) {
22
+ const trimmed = host.trim();
23
+ // Bracketed IPv6: "[...]:port" or "[...]"
24
+ if (trimmed.startsWith('[')) {
25
+ const end = trimmed.indexOf(']');
26
+ if (end !== -1) {
27
+ return trimmed.slice(1, end).toLowerCase();
28
+ }
29
+ }
30
+ // Unbracketed IPv6 (e.g. ::1, 2001:db8::1) — never strip on last ":digits"
31
+ if (trimmed.includes('::')) {
32
+ return trimmed.toLowerCase();
33
+ }
34
+ // IPv4 or DNS name with ":port" (port = decimal digits only)
35
+ const lastColon = trimmed.lastIndexOf(':');
36
+ if (lastColon > 0) {
37
+ const after = trimmed.slice(lastColon + 1);
38
+ if (/^\d+$/.test(after)) {
39
+ return trimmed.slice(0, lastColon).toLowerCase();
40
+ }
41
+ }
42
+ return trimmed.toLowerCase();
43
+ }
13
44
  /**
14
45
  * Base proxy class with common methods
15
46
  * @public
@@ -106,8 +137,7 @@ export class ProxyBase extends ProxyHandler {
106
137
  * @param {NextRequest} req request
107
138
  */
108
139
  getHostHeader(req) {
109
- var _a;
110
- return req.headers.get('x-forwarded-host') || ((_a = req.headers.get('host')) === null || _a === void 0 ? void 0 : _a.split(':')[0]);
140
+ return getHostnameFromHostHeader(req.headers.get('x-forwarded-host') || req.headers.get('host') || '');
111
141
  }
112
142
  /**
113
143
  * Get site information. If site name is stored in cookie, use it, otherwise resolve by hostname
@@ -59,7 +59,7 @@ export class RedirectsProxy extends ProxyBase {
59
59
  }
60
60
  site = this.getSite(req, res);
61
61
  // Find the redirect from result of RedirectService
62
- const existsRedirect = await this.getExistsRedirect(req, site.name);
62
+ const existsRedirect = await this.getExistsRedirect(req, site.name, language);
63
63
  if (!existsRedirect) {
64
64
  debug.redirects('skipped (redirect does not exist)');
65
65
  return res;
@@ -196,19 +196,38 @@ export class RedirectsProxy extends ProxyBase {
196
196
  * @returns Promise<RedirectInfo | undefined>
197
197
  * @private
198
198
  */
199
- async getExistsRedirect(req, siteName) {
199
+ async getExistsRedirect(req, siteName, requestLocale) {
200
200
  if (!this.redirectsService) {
201
201
  return undefined;
202
202
  }
203
203
  const { pathname: incomingURL, search: incomingQS = '' } = this.normalizeUrl(req.nextUrl.clone());
204
- const locale = this.getLanguage(req);
205
204
  const normalizedPath = incomingURL.replace(/\/*$/gi, '').toLowerCase();
206
205
  const redirects = await this.redirectsService.fetchRedirects(siteName);
207
- const modifyRedirects = structuredClone(redirects);
206
+ // using locale of current request (from URL, headers or otherwise), used to match versioned redirect rules
207
+ const matchedLocaleRedirect = this.matchRedirectItemRedirect(redirects, requestLocale, normalizedPath);
208
+ if (matchedLocaleRedirect) {
209
+ return matchedLocaleRedirect;
210
+ }
211
+ // locale of the url - if present in the url, used for redirect map matching
212
+ const urlLocale = req.nextUrl.locale;
213
+ return this.matchFromRedirectMapRedirect(redirects, urlLocale, incomingURL, incomingQS);
214
+ }
215
+ /**
216
+ * Matches redirect-map rules without a `locale` field against the incoming URL (static or regex patterns).
217
+ * @param {RedirectResult[]} redirects All redirects from the service (non-locale entries are filtered inside).
218
+ * @param {string} urlLocale Locale segment from the request URL (`nextUrl.locale`).
219
+ * @param {string} incomingURL Original pathname used for regex tests.
220
+ * @param {string} incomingQS Query string including leading `?` if present.
221
+ * @returns {RedirectResult | undefined} First matching redirect or undefined.
222
+ * @private
223
+ */
224
+ matchFromRedirectMapRedirect(redirects, urlLocale, incomingURL, incomingQS) {
225
+ const nonLocaleRedirects = redirects.filter((redirect) => !redirect.locale);
208
226
  let matchedQueryString;
209
- const localePath = `/${locale.toLowerCase()}${normalizedPath}`;
210
- return modifyRedirects.length
211
- ? modifyRedirects.find((redirect) => {
227
+ const normalizedPath = incomingURL.replace(/\/*$/gi, '').toLowerCase();
228
+ const localePath = `/${urlLocale.toLowerCase()}${normalizedPath}`;
229
+ return nonLocaleRedirects.length
230
+ ? nonLocaleRedirects.find((redirect) => {
212
231
  // process static URL (non-regex) rules
213
232
  if (isRegexOrUrl(redirect.pattern) === 'url') {
214
233
  const urlArray = redirect.pattern.endsWith('/')
@@ -230,14 +249,14 @@ export class RedirectsProxy extends ProxyBase {
230
249
  // process regex rules
231
250
  // Modify the redirect pattern to ignore the language prefix in the path
232
251
  // And escapes non-special "?" characters in a string or regex.
233
- redirect.pattern = escapeNonSpecialQuestionMarks('^' + redirect.pattern.replace(new RegExp(`^[^]?/${locale}/`, 'gi'), '') // ensure function thinks input is regex
252
+ redirect.pattern = escapeNonSpecialQuestionMarks('^' + redirect.pattern.replace(new RegExp(`^[^]?/${urlLocale}/`, 'gi'), '') // ensure function thinks input is regex
234
253
  );
235
254
  // Prepare the redirect pattern as a regular expression, making it more flexible for matching URLs
236
255
  redirect.pattern = `/^\/${redirect.pattern
237
256
  .replace(/^\/|\/$/g, '') // Removes leading and trailing slashes
238
257
  .replace(/^\^\/|\/\$$/g, '') // Removes unnecessary start (^) and end ($) anchors
239
258
  .replace(/^\^|\$$/g, '') // Further cleans up anchors
240
- .replace(/\$\/gi$/g, '')}[\/]?$/i`; // Ensures the pattern allows an optional trailing slash
259
+ .replace(/\$\/g$/g, '')}[\/]?$/i`; // Ensures the pattern allows an optional trailing slash
241
260
  // Redirect pattern matches the full incoming URL with query string present
242
261
  matchedQueryString = [
243
262
  regexParser(redirect.pattern).test(`/${localePath}${incomingQS}`),
@@ -247,9 +266,28 @@ export class RedirectsProxy extends ProxyBase {
247
266
  : undefined;
248
267
  // Save the matched query string (if found) into the redirect object
249
268
  redirect.matchedQueryString = matchedQueryString || '';
250
- return (!!(regexParser(redirect.pattern).test(`/${req.nextUrl.locale}${incomingURL}`) ||
269
+ return (!!(regexParser(redirect.pattern).test(`/${urlLocale}${incomingURL}`) ||
251
270
  regexParser(redirect.pattern).test(incomingURL) ||
252
- matchedQueryString) && (redirect.locale ? redirect.locale.toLowerCase() === locale.toLowerCase() : true));
271
+ matchedQueryString) &&
272
+ (redirect.locale ? redirect.locale.toLowerCase() === urlLocale.toLowerCase() : true));
273
+ })
274
+ : undefined;
275
+ }
276
+ /**
277
+ * Processes redirect rules from redirect items (language-versioned)
278
+ * @param {RedirectResult[]} redirects redirect entries from Edge
279
+ * @param {string} locale current request locale
280
+ * @param {string} currentPath current request path
281
+ * @returns {RedirectResult | undefined} matched redirect item redirect result or undefined
282
+ * @private
283
+ */
284
+ matchRedirectItemRedirect(redirects, locale, currentPath) {
285
+ const nonLocalePath = currentPath.replace(new RegExp(`^\/?${locale}\/`, 'i'), '/');
286
+ return redirects.length
287
+ ? redirects.find((redirect) => {
288
+ const patternPath = redirect.pattern.replace(/\/*$/g, '').toLowerCase();
289
+ // locale rules are easy and nice
290
+ return redirect.locale === locale && patternPath === nonLocalePath;
253
291
  })
254
292
  : undefined;
255
293
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sitecore-content-sdk/nextjs",
3
- "version": "2.0.2",
3
+ "version": "2.1.0-canary.10",
4
4
  "main": "dist/cjs/index.js",
5
5
  "module": "dist/esm/index.js",
6
6
  "sideEffects": false,
@@ -32,8 +32,8 @@
32
32
  "url": "https://github.com/sitecore/content-sdk/issues"
33
33
  },
34
34
  "devDependencies": {
35
- "@sitecore-content-sdk/analytics-core": "^2.0.2",
36
- "@sitecore-content-sdk/personalize": "^2.0.2",
35
+ "@sitecore-content-sdk/analytics-core": "2.1.0-canary.10",
36
+ "@sitecore-content-sdk/personalize": "2.1.0-canary.10",
37
37
  "@stylistic/eslint-plugin": "^5.2.2",
38
38
  "@testing-library/dom": "^10.4.0",
39
39
  "@testing-library/react": "^16.3.0",
@@ -63,7 +63,7 @@
63
63
  "glob": "^11.0.2",
64
64
  "jsdom": "^26.1.0",
65
65
  "mocha": "^11.2.2",
66
- "next": "^16.1.1",
66
+ "next": "^16.2.0",
67
67
  "nock": "14.0.0-beta.7",
68
68
  "nyc": "^17.1.0",
69
69
  "prettier": "^2.8.0",
@@ -76,10 +76,10 @@
76
76
  "typescript": "~5.8.3"
77
77
  },
78
78
  "peerDependencies": {
79
- "@sitecore-content-sdk/analytics-core": "^2.0.2",
80
- "@sitecore-content-sdk/events": "^2.0.2",
81
- "@sitecore-content-sdk/personalize": "^2.0.2",
82
- "next": "^16.1.1",
79
+ "@sitecore-content-sdk/analytics-core": "^2.1.0-canary.3",
80
+ "@sitecore-content-sdk/events": "^2.1.0-canary.3",
81
+ "@sitecore-content-sdk/personalize": "^2.1.0-canary.3",
82
+ "next": "^16.2.0",
83
83
  "react": "^19.2.1",
84
84
  "react-dom": "^19.2.1",
85
85
  "typescript": "^5.4.0"
@@ -91,9 +91,10 @@
91
91
  },
92
92
  "dependencies": {
93
93
  "@babel/parser": "^7.27.2",
94
- "@sitecore-content-sdk/content": "^2.0.2",
95
- "@sitecore-content-sdk/core": "^2.0.2",
96
- "@sitecore-content-sdk/react": "^2.0.2",
94
+ "@sitecore-content-sdk/content": "2.1.0-canary.10",
95
+ "@sitecore-content-sdk/core": "2.1.0-canary.10",
96
+ "@sitecore-content-sdk/events": "2.1.0-canary.10",
97
+ "@sitecore-content-sdk/react": "2.1.0-canary.10",
97
98
  "recast": "^0.23.11",
98
99
  "regex-parser": "^2.3.1",
99
100
  "sync-disk-cache": "^2.1.0"
@@ -177,7 +178,7 @@
177
178
  },
178
179
  "description": "",
179
180
  "types": "types/index.d.ts",
180
- "gitHead": "b76e6169344e65141a96238321b74ef92202b436",
181
+ "gitHead": "f4f6bb8a4ac09e5d721e8a4a361c8ad980997e6c",
181
182
  "files": [
182
183
  "dist",
183
184
  "types",
@@ -1 +1 @@
1
- {"version":3,"file":"Link.d.ts","sourceRoot":"","sources":["../../src/components/Link.tsx"],"names":[],"mappings":"AACA,OAAO,KAA0B,MAAM,OAAO,CAAC;AAC/C,OAAiB,EAAE,SAAS,IAAI,aAAa,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,EAIL,SAAS,IAAI,cAAc,EAC5B,MAAM,6BAA6B,CAAC;AAErC;;GAEG;AACH,QAAA,MAAM,sBAAsB,uFAQlB,CAAC;AAEX;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,cAAc,GAAG;IACvC;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B,GAAG,IAAI,CAAC,aAAa,EAAE,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;AAOjE;;;GAGG;AACH,eAAO,MAAM,IAAI,kGAiEhB,CAAC"}
1
+ {"version":3,"file":"Link.d.ts","sourceRoot":"","sources":["../../src/components/Link.tsx"],"names":[],"mappings":"AACA,OAAO,KAA0B,MAAM,OAAO,CAAC;AAC/C,OAAiB,EAAE,SAAS,IAAI,aAAa,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,EAIL,SAAS,IAAI,cAAc,EAC5B,MAAM,6BAA6B,CAAC;AAGrC;;GAEG;AACH,QAAA,MAAM,sBAAsB,uFAQlB,CAAC;AAEX;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,cAAc,GAAG;IACvC;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B,GAAG,IAAI,CAAC,aAAa,EAAE,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;AAOjE;;;GAGG;AACH,eAAO,MAAM,IAAI,kGAqEhB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"analytics-adapter.d.ts","sourceRoot":"","sources":["../../../src/initialization/proxy/analytics-adapter.ts"],"names":[],"mappings":"AAKA,OAAO,EAEL,gBAAgB,EACjB,MAAM,+CAA+C,CAAC;AAEvD,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAExD;;;GAGG;AACH,MAAM,WAAW,qBAAsB,SAAQ,gBAAgB;IAC7D;;OAEG;IACH,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,WAAW,EACpB,QAAQ,EAAE,YAAY,GACrB,qBAAqB,CA2DvB;AAED;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,SAAS,WAAW,KAAG,MAAM,GAAG,IAI3D,CAAC"}
1
+ {"version":3,"file":"analytics-adapter.d.ts","sourceRoot":"","sources":["../../../src/initialization/proxy/analytics-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,gBAAgB,EAEjB,MAAM,+CAA+C,CAAC;AAEvD,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAExD;;;GAGG;AACH,MAAM,WAAW,qBAAsB,SAAQ,gBAAgB;IAC7D;;OAEG;IACH,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,WAAW,EACpB,QAAQ,EAAE,YAAY,GACrB,qBAAqB,CA+DvB;AAED;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,SAAS,WAAW,KAAG,MAAM,GAAG,IAI3D,CAAC"}