diff --git a/.changeset/slick-nights-double.md b/.changeset/slick-nights-double.md new file mode 100644 index 000000000..6e18fc7e9 --- /dev/null +++ b/.changeset/slick-nights-double.md @@ -0,0 +1,5 @@ +--- +"@headstartwp/next": patch +--- + +Fix issue with rewrites in multisite setups diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c107013d..6ee516c29 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,19 @@ "phpsab.snifferShowSources": true, "[php]": { "editor.defaultFormatter": "valeryanm.vscode-phpsab" - } + }, + "[javascript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "editor.formatOnSave": true, + "biome.enabled": false } \ No newline at end of file diff --git a/packages/next/src/config/__tests__/withHeadstartWPConfig.ts b/packages/next/src/config/__tests__/withHeadstartWPConfig.ts new file mode 100644 index 000000000..601fe2ac4 --- /dev/null +++ b/packages/next/src/config/__tests__/withHeadstartWPConfig.ts @@ -0,0 +1,356 @@ +import { setHeadstartWPConfig } from '@headstartwp/core/utils'; +import { Rewrite } from 'next/dist/lib/load-custom-routes'; +import { withHeadstartWPConfig } from '../withHeadstartWPConfig'; + +// Mock fs module +jest.mock('fs', () => ({ + existsSync: jest.fn(), + readFileSync: jest.fn(), +})); + +// Mock path module +jest.mock('path', () => ({ + join: jest.fn((...args) => args.join('/')), + resolve: jest.fn((...args) => args.join('/')), + normalize: jest.fn((path) => path), +})); + +// Mock require.resolve for next package.json +jest.mock('module', () => ({ + ...jest.requireActual('module'), + createRequire: jest.fn(() => ({ + resolve: jest.fn((path) => { + if (path === 'next/package.json') { + return '/mock/path/next/package.json'; + } + throw new Error('Module not found'); + }), + })), +})); + +describe('withHeadstartWPConfig - Host Check', () => { + let mockExistsSync: jest.Mock; + let mockReadFileSync: jest.Mock; + + beforeEach(() => { + // Reset config before each test + setHeadstartWPConfig({}); + + // Get the mocked functions + // eslint-disable-next-line global-require + const fs = require('fs'); + mockExistsSync = fs.existsSync; + mockReadFileSync = fs.readFileSync; + + // Mock file system to return a config file + mockExistsSync.mockImplementation((path: string) => { + if (path.includes('headstartwp.config.js') || path.includes('headless.config.js')) { + return true; + } + return false; + }); + + // Mock readFileSync to return a valid config + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '14.0.0' })); + }); + + describe('Single Site Configuration', () => { + it('should add has check when site.host is explicitly defined', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + host: 'example.com', + }; + + const nextConfig = withHeadstartWPConfig({}, headlessConfig); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + expect(rewrites).toHaveLength(8); + + // Check that all rewrites have the has check + (rewrites as Rewrite[]).forEach((rewrite) => { + expect(rewrite).toHaveProperty('has'); + expect(rewrite.has).toEqual([ + { type: 'header', key: 'host', value: 'example.com' }, + ]); + }); + }); + + it('should add has check when host is inferred from hostUrl', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + hostUrl: 'https://example.com', + }; + + const nextConfig = withHeadstartWPConfig({}, headlessConfig); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + expect(rewrites).toHaveLength(8); + + // Check that all rewrites have the has check with inferred host + (rewrites as Rewrite[]).forEach((rewrite) => { + expect(rewrite).toHaveProperty('has'); + expect(rewrite.has).toEqual([ + { type: 'header', key: 'host', value: 'example.com' }, + ]); + }); + }); + + it('should not add has check when neither host nor hostUrl is defined', 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); + + // Check that no rewrites have the has check + (rewrites as Rewrite[]).forEach((rewrite) => { + expect(rewrite).not.toHaveProperty('has'); + }); + }); + + it('should handle invalid hostUrl gracefully', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + hostUrl: 'invalid-url', + }; + + const nextConfig = withHeadstartWPConfig({}, headlessConfig); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + expect(rewrites).toHaveLength(8); + + // Check that no rewrites have the has check due to invalid URL + (rewrites as Rewrite[]).forEach((rewrite) => { + expect(rewrite).not.toHaveProperty('has'); + }); + }); + + it('should prefer explicitly defined host over hostUrl', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + host: 'explicit.example.com', + hostUrl: 'https://inferred.example.com', + }; + + const nextConfig = withHeadstartWPConfig({}, headlessConfig); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + expect(rewrites).toHaveLength(8); + + // Check that all rewrites use the explicitly defined host + (rewrites as Rewrite[]).forEach((rewrite) => { + expect(rewrite).toHaveProperty('has'); + expect(rewrite.has).toEqual([ + { type: 'header', key: 'host', value: 'explicit.example.com' }, + ]); + }); + }); + }); + + describe('Multisite Configuration', () => { + it('should add different has checks for different sites', async () => { + const headlessConfig = { + sites: [ + { + sourceUrl: 'https://wp.site1.com', + host: 'site1.com', + }, + { + sourceUrl: 'https://wp.site2.com', + hostUrl: 'https://site2.com', + }, + { + sourceUrl: 'https://wp.site3.com', + // No host or hostUrl + }, + ], + }; + + const nextConfig = withHeadstartWPConfig({}, headlessConfig); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + expect(rewrites).toHaveLength(24); // 8 rewrites per site + + // First 8 rewrites should have site1.com host check + for (let i = 0; i < 8; i++) { + expect(rewrites[i]).toHaveProperty('has'); + expect(rewrites[i].has).toEqual([ + { type: 'header', key: 'host', value: 'site1.com' }, + ]); + } + + // Next 8 rewrites should have site2.com host check (inferred from hostUrl) + for (let i = 8; i < 16; i++) { + expect(rewrites[i]).toHaveProperty('has'); + expect(rewrites[i].has).toEqual([ + { type: 'header', key: 'host', value: 'site2.com' }, + ]); + } + + // Last 8 rewrites should not have has check (no host defined) + for (let i = 16; i < 24; i++) { + expect(rewrites[i]).not.toHaveProperty('has'); + } + }); + + it('should handle mixed host configurations in multisite', async () => { + const headlessConfig = { + sites: [ + { + sourceUrl: 'https://wp.example.com', + host: 'example.com', + hostUrl: 'https://example.com', + }, + { + sourceUrl: 'https://wp.test.com', + hostUrl: 'https://test.com', + }, + ], + }; + + const nextConfig = withHeadstartWPConfig({}, headlessConfig); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + expect(rewrites).toHaveLength(16); // 8 rewrites per site + + // All rewrites should have has checks + (rewrites as Rewrite[]).forEach((rewrite) => { + expect(rewrite).toHaveProperty('has'); + expect(rewrite.has).toHaveLength(1); + expect(rewrite.has?.[0]).toHaveProperty('type', 'header'); + expect(rewrite.has?.[0]).toHaveProperty('key', 'host'); + expect(['example.com', 'test.com']).toContain(rewrite.has?.[0].value); + }); + }); + }); + + describe('Rewrite Structure', () => { + it('should maintain correct rewrite structure with has check', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + host: 'example.com', + }; + + const nextConfig = withHeadstartWPConfig({}, headlessConfig); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + + // Check specific rewrite patterns + const cacheHealthcheckRewrite = (rewrites as Rewrite[]).find( + (r) => r.source === '/cache-healthcheck', + ); + expect(cacheHealthcheckRewrite).toBeDefined(); + expect(cacheHealthcheckRewrite).toMatchObject({ + source: '/cache-healthcheck', + destination: '/api/cache-healthcheck', + has: [{ type: 'header', key: 'host', value: 'example.com' }], + }); + + const feedRewrite = (rewrites as Rewrite[]).find((r) => r.source === '/feed'); + expect(feedRewrite).toBeDefined(); + expect(feedRewrite).toMatchObject({ + source: '/feed', + destination: 'https://wp.example.com/feed/?rewrite_urls=1', + has: [{ type: 'header', key: 'host', value: 'example.com' }], + }); + }); + + it('should work with existing rewrites', async () => { + const existingRewrites = [ + { + source: '/custom-rewrite', + destination: '/custom-destination', + }, + ]; + + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + host: 'example.com', + }; + + const nextConfig = withHeadstartWPConfig( + { rewrites: () => Promise.resolve(existingRewrites) }, + headlessConfig, + ); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + expect(rewrites).toHaveLength(9); // 1 existing + 8 default + + // Check that existing rewrites are preserved + expect(rewrites[0]).toEqual(existingRewrites[0]); + + // Check that default rewrites have has checks + for (let i = 1; i < 9; i++) { + expect(rewrites[i]).toHaveProperty('has'); + } + }); + }); + + describe('Edge Cases', () => { + it('should handle empty sites array', async () => { + const headlessConfig = { + sites: [], + }; + + const nextConfig = withHeadstartWPConfig({}, headlessConfig); + const rewrites = await nextConfig.rewrites?.(); + + expect(Array.isArray(rewrites)).toBe(true); + expect(rewrites).toHaveLength(0); + }); + + it('should handle hostUrl with port', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + hostUrl: 'https://example.com:3000', + }; + + const nextConfig = withHeadstartWPConfig({}, headlessConfig); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + expect(rewrites).toHaveLength(8); + + // Check that host includes port + (rewrites as Rewrite[]).forEach((rewrite) => { + expect(rewrite).toHaveProperty('has'); + expect(rewrite.has).toEqual([ + { type: 'header', key: 'host', value: 'example.com:3000' }, + ]); + }); + }); + + it('should handle hostUrl with subdomain', async () => { + const headlessConfig = { + sourceUrl: 'https://wp.example.com', + hostUrl: 'https://subdomain.example.com', + }; + + const nextConfig = withHeadstartWPConfig({}, headlessConfig); + const rewrites = (await nextConfig.rewrites?.()) ?? []; + + expect(Array.isArray(rewrites)).toBe(true); + expect(rewrites).toHaveLength(8); + + // Check that subdomain is preserved + (rewrites as Rewrite[]).forEach((rewrite) => { + expect(rewrite).toHaveProperty('has'); + expect(rewrite.has).toEqual([ + { type: 'header', key: 'host', value: 'subdomain.example.com' }, + ]); + }); + }); + }); +}); diff --git a/packages/next/src/config/withHeadstartWPConfig.ts b/packages/next/src/config/withHeadstartWPConfig.ts index 42f85a85e..f1080c190 100644 --- a/packages/next/src/config/withHeadstartWPConfig.ts +++ b/packages/next/src/config/withHeadstartWPConfig.ts @@ -1,8 +1,8 @@ -import { ConfigError, HeadlessConfig, getSite } from '@headstartwp/core'; -import { NextConfig } from 'next'; +import { ConfigError, getSite, type HeadlessConfig } from '@headstartwp/core'; import fs from 'fs'; +import type { NextConfig } from 'next'; import path from 'path'; -import { ModifySourcePlugin, ConcatOperation } from './plugins/ModifySourcePlugin'; +import { ConcatOperation, ModifySourcePlugin } from './plugins/ModifySourcePlugin'; type RemotePattern = { protocol?: 'http' | 'https'; @@ -209,28 +209,50 @@ export function withHeadstartWPConfig( const shouldRewriteYoastSEOUrls = site.integrations?.yoastSEO?.enable === true ? 1 : 0; + // Extract site host for has check + let siteHost = site.host; + + // If host is not defined but hostUrl is, infer host from hostUrl + if (typeof siteHost === 'undefined' && typeof site.hostUrl !== 'undefined') { + try { + const url = new URL(site.hostUrl); + siteHost = url.host; + } catch (e) { + // do nothing, keep siteHost undefined + } + } + + const hasHostCheck = siteHost && { + has: [{ type: 'header', key: 'host', value: siteHost }], + }; + const defaultRewrites = [ { source: `${prefix}/cache-healthcheck`, destination: '/api/cache-healthcheck', + ...hasHostCheck, }, { source: `${prefix}/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, }, { source: `${prefix}/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`, 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 @@ -238,6 +260,7 @@ export function withHeadstartWPConfig( // eslint-disable-next-line source: `${prefix}/: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. @@ -246,12 +269,14 @@ export function withHeadstartWPConfig( // between WordPress and NextJS app. { // eslint-disable-next-line - source: '/:path(.*main-sitemap\.xsl)', + source: "/:path(.*main-sitemap\.xsl)", destination: `${wpUrl}/:path`, + ...hasHostCheck, }, { source: `${prefix}/ads.txt`, destination: `${wpUrl}/ads.txt`, + ...hasHostCheck, }, ]; if (Array.isArray(rewrites)) { @@ -382,7 +407,9 @@ export function withHeadstartWPConfig( export function withHeadlessConfig( nextConfig: NextConfig = {}, headlessConfig: HeadlessConfig = {}, - withHeadstarWPConfigOptions: { injectConfig: boolean } = { injectConfig: true }, + withHeadstarWPConfigOptions: { injectConfig: boolean } = { + injectConfig: true, + }, ) { return withHeadstartWPConfig(nextConfig, headlessConfig, withHeadstarWPConfigOptions); }