diff --git a/.changeset/fifty-kiwis-own.md b/.changeset/fifty-kiwis-own.md new file mode 100644 index 000000000..c84fdf780 --- /dev/null +++ b/.changeset/fifty-kiwis-own.md @@ -0,0 +1,5 @@ +--- +"@headstartwp/next": patch +--- + +Fix issue with locale rewrites on pages router diff --git a/packages/next/src/config/__tests__/withHeadstartWPConfig.ts b/packages/next/src/config/__tests__/withHeadstartWPConfig.ts index 601fe2ac4..c03dc0309 100644 --- a/packages/next/src/config/__tests__/withHeadstartWPConfig.ts +++ b/packages/next/src/config/__tests__/withHeadstartWPConfig.ts @@ -353,4 +353,296 @@ describe('withHeadstartWPConfig - Host Check', () => { }); }); }); + + describe('Locale/i18n Handling', () => { + beforeEach(() => { + // Mock file system to ensure we're using pages router (not app router) + // by returning false for app directory checks + // eslint-disable-next-line global-require + const fs = require('fs'); + const mockExistsSync = fs.existsSync; + mockExistsSync.mockImplementation((path: string) => { + if (path.includes('headstartwp.config.js') || path.includes('headless.config.js')) { + return true; + } + // Return false for app directory to ensure pages router + if (path.includes('/app') || path.includes('src/app')) { + return false; + } + return false; + }); + }); + + it('should create locale-aware rewrites when i18n is configured for pages router', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + }; + + const nextConfig = withHeadstartWPConfig( + { + i18n: { + locales: ['en', 'es', 'fr'], + defaultLocale: 'en', + }, + }, + headlessConfig, + ); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + // Should have 7 paths * 4 variants (default + 3 locales) = 28 rewrites + // Plus 1 sitemap xsl rewrite without locale + 3 with locales = 4 + // Total: 28 + 4 = 32 + expect(rewrites).toHaveLength(32); + + // Check that we have rewrites for each locale + const cacheHealthcheckRewrites = (rewrites as Rewrite[]).filter((r) => + r.source.includes('cache-healthcheck'), + ); + expect(cacheHealthcheckRewrites).toHaveLength(4); // default + 3 locales + + // Check default rewrite (no locale) + const defaultRewrite = cacheHealthcheckRewrites.find( + (r) => r.source === '/cache-healthcheck', + ); + expect(defaultRewrite).toBeDefined(); + expect(defaultRewrite?.destination).toBe('/api/cache-healthcheck'); + + // Check locale-specific rewrites + const enRewrite = cacheHealthcheckRewrites.find( + (r) => r.source === '/en/cache-healthcheck', + ); + expect(enRewrite).toBeDefined(); + expect(enRewrite?.destination).toBe('/api/cache-healthcheck'); + + const esRewrite = cacheHealthcheckRewrites.find( + (r) => r.source === '/es/cache-healthcheck', + ); + expect(esRewrite).toBeDefined(); + expect(esRewrite?.destination).toBe('/api/cache-healthcheck'); + + const frRewrite = cacheHealthcheckRewrites.find( + (r) => r.source === '/fr/cache-healthcheck', + ); + expect(frRewrite).toBeDefined(); + expect(frRewrite?.destination).toBe('/api/cache-healthcheck'); + }); + + it('should not create locale-aware rewrites when i18n is not configured', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + }; + + const nextConfig = withHeadstartWPConfig({}, headlessConfig); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + expect(rewrites).toHaveLength(8); // Standard 8 rewrites without locales + + // Verify no locale prefixes in sources + (rewrites as Rewrite[]).forEach((rewrite) => { + expect(rewrite.source).not.toMatch(/^\/(en|es|fr)\//); + }); + }); + + it('should create locale-aware rewrites for multisite with i18n', async () => { + const headlessConfig = { + sites: [ + { + sourceUrl: 'https://wp.site1.com', + host: 'site1.com', + }, + { + sourceUrl: 'https://wp.site2.com', + host: 'site2.com', + }, + ], + }; + + const nextConfig = withHeadstartWPConfig( + { + i18n: { + locales: ['en', 'es'], + defaultLocale: 'en', + }, + }, + headlessConfig, + ); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + // 2 sites * 7 paths * 3 variants (default + 2 locales) = 42 + // Plus 2 sites * 3 sitemap xsl rewrites (default + 2 locales) = 6 + // Total: 42 + 6 = 48 + expect(rewrites).toHaveLength(48); + + // Check multisite prefix with locales + const site1CacheRewrites = (rewrites as Rewrite[]).filter( + (r) => r.source.includes('_sites/:site') && r.source.includes('cache-healthcheck'), + ); + expect(site1CacheRewrites.length).toBeGreaterThan(0); + + // Check that we have rewrites with multisite prefix and locales + const multisiteEnRewrite = (rewrites as Rewrite[]).find( + (r) => r.source === '/_sites/:site/en/cache-healthcheck', + ); + expect(multisiteEnRewrite).toBeDefined(); + + const multisiteEsRewrite = (rewrites as Rewrite[]).find( + (r) => r.source === '/_sites/:site/es/cache-healthcheck', + ); + expect(multisiteEsRewrite).toBeDefined(); + + const multisiteDefaultRewrite = (rewrites as Rewrite[]).find( + (r) => r.source === '/_sites/:site/cache-healthcheck', + ); + expect(multisiteDefaultRewrite).toBeDefined(); + }); + + it('should handle sitemap stylesheet rewrites with locales', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + }; + + const nextConfig = withHeadstartWPConfig( + { + i18n: { + locales: ['en', 'es'], + defaultLocale: 'en', + }, + }, + headlessConfig, + ); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + // Find sitemap xsl rewrites - the pattern uses escaped dots + const sitemapXslRewrites = (rewrites as Rewrite[]).filter((r) => + r.source.includes('main-sitemap'), + ); + + // Should have default + 2 locales = 3 rewrites + expect(sitemapXslRewrites).toHaveLength(3); + + // Check default - pattern uses \\. for escaped dot + const defaultXsl = sitemapXslRewrites.find((r) => + r.source.includes(':path(.*main-sitemap'), + ); + expect(defaultXsl).toBeDefined(); + expect(defaultXsl?.source).toBe('/:path(.*main-sitemap\\.xsl)'); + + // Check locale-specific + const enXsl = sitemapXslRewrites.find((r) => r.source.startsWith('/en/')); + expect(enXsl).toBeDefined(); + expect(enXsl?.source).toBe('/en/:path(.*main-sitemap\\.xsl)'); + + const esXsl = sitemapXslRewrites.find((r) => r.source.startsWith('/es/')); + expect(esXsl).toBeDefined(); + expect(esXsl?.source).toBe('/es/:path(.*main-sitemap\\.xsl)'); + }); + + it('should not apply i18n rewrites when using app router', async () => { + // Mock app router by returning true for app directory + // eslint-disable-next-line global-require + const fs = require('fs'); + const mockExistsSync = fs.existsSync; + mockExistsSync.mockImplementation((path: string) => { + if (path.includes('headstartwp.config.js') || path.includes('headless.config.js')) { + return true; + } + // Return true for app directory to simulate app router + if (path.includes('/app') || path.includes('src/app')) { + return true; + } + return false; + }); + + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + }; + + const nextConfig = withHeadstartWPConfig( + { + i18n: { + locales: ['en', 'es'], + defaultLocale: 'en', + }, + }, + headlessConfig, + ); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + // App router should not use Next.js i18n, so should have standard 8 rewrites + expect(rewrites).toHaveLength(8); + + // Verify no locale prefixes (app router uses its own i18n system) + (rewrites as Rewrite[]).forEach((rewrite) => { + expect(rewrite.source).not.toMatch(/^\/(en|es)\//); + }); + }); + + it('should handle feed rewrites with locales', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + }; + + const nextConfig = withHeadstartWPConfig( + { + i18n: { + locales: ['en', 'de'], + defaultLocale: 'en', + }, + }, + headlessConfig, + ); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + // Find feed rewrites + const feedRewrites = (rewrites as Rewrite[]).filter((r) => r.source.includes('feed')); + + // Should have default + 2 locales = 3 rewrites + expect(feedRewrites).toHaveLength(3); + + // Check all have correct destination + feedRewrites.forEach((rewrite) => { + expect(rewrite.destination).toBe('https://wp.example.com/feed/?rewrite_urls=1'); + }); + + // Check sources + const defaultFeed = feedRewrites.find((r) => r.source === '/feed'); + expect(defaultFeed).toBeDefined(); + + const enFeed = feedRewrites.find((r) => r.source === '/en/feed'); + expect(enFeed).toBeDefined(); + + const deFeed = feedRewrites.find((r) => r.source === '/de/feed'); + expect(deFeed).toBeDefined(); + }); + + it('should preserve host checks when i18n is configured', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + host: 'example.com', + }; + + const nextConfig = withHeadstartWPConfig( + { + i18n: { + locales: ['en', 'es'], + defaultLocale: 'en', + }, + }, + headlessConfig, + ); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + // All rewrites should have host check + (rewrites as Rewrite[]).forEach((rewrite) => { + expect(rewrite).toHaveProperty('has'); + expect(rewrite.has).toEqual([ + { type: 'header', key: 'host', value: 'example.com' }, + ]); + }); + }); + }); }); diff --git a/packages/next/src/config/withHeadstartWPConfig.ts b/packages/next/src/config/withHeadstartWPConfig.ts index f1080c190..79242c405 100644 --- a/packages/next/src/config/withHeadstartWPConfig.ts +++ b/packages/next/src/config/withHeadstartWPConfig.ts @@ -197,6 +197,15 @@ export function withHeadstartWPConfig( const rewrites = typeof nextConfig.rewrites === 'function' ? await nextConfig.rewrites() : []; + // Check if i18n is configured for pages router + const hasI18n = + !isUsingAppRouter && + nextConfig.i18n !== undefined && + nextConfig.i18n !== null && + Array.isArray(nextConfig.i18n.locales) && + nextConfig.i18n.locales.length > 0; + const locales = hasI18n && nextConfig.i18n ? nextConfig.i18n.locales : []; + sites.forEach((rawSite) => { const site = getSite(rawSite); const wpUrl = site.sourceUrl; @@ -226,59 +235,95 @@ export function withHeadstartWPConfig( has: [{ type: 'header', key: 'host', value: siteHost }], }; - const defaultRewrites = [ - { - source: `${prefix}/cache-healthcheck`, - destination: '/api/cache-healthcheck', - ...hasHostCheck, - }, + // Helper function to create rewrite sources with locale support + // Returns an array of sources to handle both localized and non-localized paths + const createRewriteSources = (path: string): string[] => { + if (hasI18n && locales.length > 0) { + // For pages router with i18n, create rewrites for each locale plus default + const sources: string[] = []; + // Add rewrite without locale (for default locale or when locale is stripped) + sources.push(`${prefix}${path}`); + // Add rewrites for each locale + locales.forEach((locale) => { + sources.push(`${prefix}/${locale}${path}`); + }); + return sources; + } + return [`${prefix}${path}`]; + }; + + // Build rewrites array - create multiple rewrites for each path when i18n is enabled + const defaultRewrites: Array<{ + source: string; + destination: string; + has?: Array<{ type: string; key: string; value: string }>; + }> = []; + + // Define rewrite paths and their destinations + const rewritePaths = [ + { path: '/cache-healthcheck', destination: '/api/cache-healthcheck' }, { - source: `${prefix}/block-library.css`, + path: '/block-library.css', destination: `${wpUrl}/wp-includes/css/dist/block-library/style.min.css`, - ...hasHostCheck, - }, - { - source: `${prefix}/feed`, - destination: `${wpUrl}/feed/?rewrite_urls=1`, - ...hasHostCheck, }, + { path: '/feed', destination: `${wpUrl}/feed/?rewrite_urls=1` }, { - source: `${prefix}/robots.txt`, + path: '/robots.txt', destination: `${wpUrl}/robots.txt?rewrite_urls=${shouldRewriteYoastSEOUrls}`, - ...hasHostCheck, }, - // Yoast redirects sitemap.xml to sitemap_index.xml, - // doing this upfront to avoid being redirected to the wp domain { - source: `${prefix}/sitemap.xml`, + path: '/sitemap.xml', destination: `${wpUrl}/sitemap_index.xml?rewrite_urls=${shouldRewriteYoastSEOUrls}`, - ...hasHostCheck, }, - // this matches anything that has sitemap and ends with .xml. - // This could probably be fine tuned but this should do the trick { - // eslint-disable-next-line - source: `${prefix}/:sitemap(.*sitemap.*\.xml)`, + path: '/:sitemap(.*sitemap.*\\.xml)', destination: `${wpUrl}/:sitemap?rewrite_urls=${shouldRewriteYoastSEOUrls}`, - ...hasHostCheck, }, - // This is to match the sitemap stylesheet, - // which gets added into the sitemap xml markup by Yoast. - // And if we don't rewrite this, users may see CSP/CORS error - // due to different host domains in the url, - // between WordPress and NextJS app. { + path: '/ads.txt', + destination: `${wpUrl}/ads.txt`, + }, + ]; + + // Create rewrites for each path (with locale variants if i18n is enabled) + rewritePaths.forEach(({ path, destination }) => { + const sources = createRewriteSources(path); + sources.forEach((source) => { + defaultRewrites.push({ + source, + destination, + ...hasHostCheck, + }); + }); + }); + + // Handle sitemap stylesheet separately (it doesn't use prefix) + const sitemapXslPath = '/:path(.*main-sitemap\\.xsl)'; + if (hasI18n && locales.length > 0) { + // Add rewrite without locale + defaultRewrites.push({ // eslint-disable-next-line - source: "/:path(.*main-sitemap\.xsl)", + source: sitemapXslPath, destination: `${wpUrl}/:path`, ...hasHostCheck, - }, - { - source: `${prefix}/ads.txt`, - destination: `${wpUrl}/ads.txt`, + }); + // Add rewrites for each locale + locales.forEach((locale) => { + defaultRewrites.push({ + // eslint-disable-next-line + source: `/${locale}${sitemapXslPath}`, + destination: `${wpUrl}/:path`, + ...hasHostCheck, + }); + }); + } else { + defaultRewrites.push({ + // eslint-disable-next-line + source: sitemapXslPath, + destination: `${wpUrl}/:path`, ...hasHostCheck, - }, - ]; + }); + } if (Array.isArray(rewrites)) { rewrites.push(...defaultRewrites); } else {