diff --git a/.changeset/config.json b/.changeset/config.json index 538a20311..66c8227c7 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,5 +8,5 @@ "baseBranch": "trunk", "updateInternalDependencies": "patch", "privatePackages": { "version": true, "tag": false }, - "ignore": ["@10up/wp-nextjs", "@10up/wp-nextjs-app", "@10up/wp-multisite-nextjs-app", "@10up/wp-multisite-nextjs", "@10up/wp-multisite-i18n-nextjs"] + "ignore": ["@10up/wp-nextjs", "@10up/wp-nextjs-app", "@10up/wp-polylang-nextjs-app", "@10up/wp-multisite-nextjs-app", "@10up/wp-multisite-nextjs", "@10up/wp-multisite-i18n-nextjs"] } \ No newline at end of file diff --git a/.changeset/happy-squids-admire.md b/.changeset/happy-squids-admire.md new file mode 100644 index 000000000..c2447593e --- /dev/null +++ b/.changeset/happy-squids-admire.md @@ -0,0 +1,6 @@ +--- +"@headstartwp/core": minor +"@headstartwp/next": minor +--- + +Adding support for `i18n` routing in app router diff --git a/package-lock.json b/package-lock.json index 57a69ddee..b0e0c5a5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,6 +114,10 @@ "resolved": "projects/wp-nextjs-app", "link": true }, + "node_modules/@10up/wp-polylang-nextjs-app": { + "resolved": "projects/wp-polylang-nextjs-app", + "link": true + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "license": "Apache-2.0", @@ -2791,6 +2795,14 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz", + "integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@headstartwp/core": { "resolved": "packages/core", "link": true @@ -15387,7 +15399,6 @@ }, "node_modules/negotiator": { "version": "0.6.3", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -20950,7 +20961,7 @@ "msw": "^0.35.0", "ts-jest": "^29.0.3", "tsc-esm-fix": "^2.20.27", - "typescript": "^5.4.5" + "typescript": "^5.5.3" }, "peerDependencies": { "react": ">= 17.0.2" @@ -20983,9 +20994,10 @@ } }, "packages/core/node_modules/typescript": { - "version": "5.4.5", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20999,10 +21011,12 @@ "version": "1.4.3", "license": "MIT", "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", "@headstartwp/core": "^1.4.4", "@isaacs/ttlcache": "^1.4.1", "deepmerge": "^4.3.1", "loader-utils": "^3.2.0", + "negotiator": "^0.6.3", "schema-utils": "^4.0.0" }, "devDependencies": { @@ -21020,7 +21034,7 @@ "node-mocks-http": "^1.14.1", "ts-jest": "^29.0.1", "tsc-esm-fix": "^2.20.27", - "typescript": "^5.4.5" + "typescript": "^5.5.3" }, "peerDependencies": { "next": ">= 12.0.0", @@ -21041,7 +21055,7 @@ "ioredis-mock": "^8.9.0", "jest": "^29.0.3", "ts-jest": "^29.0.1", - "typescript": "^5.4.5" + "typescript": "^5.5.3" }, "peerDependencies": { "next": ">= 13.2.0" @@ -21053,9 +21067,10 @@ "license": "MIT" }, "packages/next-redis-cache-provider/node_modules/typescript": { - "version": "5.4.5", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21091,9 +21106,10 @@ } }, "packages/next/node_modules/typescript": { - "version": "5.4.5", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21312,7 +21328,7 @@ "@types/react-dom": "^18", "@typescript-eslint/eslint-plugin": "^7.9.0", "@typescript-eslint/parser": "^7.9.0", - "typescript": "^5.4.5" + "typescript": "^5.5.3" }, "engines": { "node": ">=18.0.0", @@ -21545,9 +21561,135 @@ } }, "projects/wp-nextjs/node_modules/typescript": { - "version": "5.4.5", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "projects/wp-polylang-nextjs-app": { + "version": "0.1.0", + "dependencies": { + "@headstartwp/core": "^1.4.3", + "@headstartwp/next": "^1.4.2", + "next": "14.2.3", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@10up/eslint-config": "^4.0.0", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.3", + "typescript": "^5" + } + }, + "projects/wp-polylang-nextjs-app/node_modules/@next/env": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", + "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==" + }, + "projects/wp-polylang-nextjs-app/node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", + "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "projects/wp-polylang-nextjs-app/node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz", + "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "projects/wp-polylang-nextjs-app/node_modules/@types/node": { + "version": "20.14.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", + "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "projects/wp-polylang-nextjs-app/node_modules/next": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", + "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==", + "dependencies": { + "@next/env": "14.2.3", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.3", + "@next/swc-darwin-x64": "14.2.3", + "@next/swc-linux-arm64-gnu": "14.2.3", + "@next/swc-linux-arm64-musl": "14.2.3", + "@next/swc-linux-x64-gnu": "14.2.3", + "@next/swc-linux-x64-musl": "14.2.3", + "@next/swc-win32-arm64-msvc": "14.2.3", + "@next/swc-win32-ia32-msvc": "14.2.3", + "@next/swc-win32-x64-msvc": "14.2.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "projects/wp-polylang-nextjs-app/node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21566,6 +21708,111 @@ "engines": { "node": ">=16.0.0" } + }, + "projects/wp-polylang-nextjs-app/node_modules/@next/swc-darwin-arm64": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", + "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "projects/wp-polylang-nextjs-app/node_modules/@next/swc-darwin-x64": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", + "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "projects/wp-polylang-nextjs-app/node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", + "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "projects/wp-polylang-nextjs-app/node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", + "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "projects/wp-polylang-nextjs-app/node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", + "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "projects/wp-polylang-nextjs-app/node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", + "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "projects/wp-polylang-nextjs-app/node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", + "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/package.json b/package.json index ecf8927b6..c297aab63 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "dev": "turbo run dev --parallel --filter=./projects/wp-nextjs --filter=./packages/core --filter=./packages/next", "dev:app": "turbo run dev --parallel --filter=./projects/wp-nextjs-app --filter=./packages/core --filter=./packages/next", "dev:app:multisite": "turbo run dev --parallel --filter=./projects/wp-multisite-nextjs-app --filter=./packages/core --filter=./packages/next", + "dev:app:polylang": "turbo run dev --parallel --filter=./projects/wp-polylang-nextjs-app --filter=./packages/core --filter=./packages/next", "dev:multisite": "turbo run dev --parallel --filter=./projects/wp-multisite-nextjs --filter=./packages/core --filter=./packages/next", + "dev:multisite:i18n": "turbo run dev --parallel --filter=./projects/wp-multisite-i18n-nextjs --filter=./packages/core --filter=./packages/next", "prepare": "husky install", "typedoc": "typedoc", "typedoc:watch": "typedoc --watch", diff --git a/packages/core/package.json b/packages/core/package.json index e18074e31..b0e6127c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -69,7 +69,7 @@ "jest": "^29.3.1", "msw": "^0.35.0", "ts-jest": "^29.0.3", - "typescript": "^5.4.5", + "typescript": "^5.5.3", "isomorphic-fetch": "^3.0.0", "tsc-esm-fix": "^2.20.27", "@types/react": "^18", diff --git a/packages/core/src/data/fetchFn/fetchAppSettings.ts b/packages/core/src/data/fetchFn/fetchAppSettings.ts index 25ba99275..10fa5c1f7 100644 --- a/packages/core/src/data/fetchFn/fetchAppSettings.ts +++ b/packages/core/src/data/fetchFn/fetchAppSettings.ts @@ -4,7 +4,7 @@ import { getHeadstartWPConfig, getObjectProperty, getWPUrl } from '../../utils'; import { AppEntity, MenuItemEntity } from '../types'; import { QueryProps } from './types'; -export type AppQueryProps

= QueryProps

& { +export type AppQueryProps

= QueryProps

& { menu?: string; blockSetting?: { blockName?: string; diff --git a/packages/core/src/data/fetchFn/types.ts b/packages/core/src/data/fetchFn/types.ts index 2dd6a51d7..bf9e4153e 100644 --- a/packages/core/src/data/fetchFn/types.ts +++ b/packages/core/src/data/fetchFn/types.ts @@ -1,6 +1,6 @@ -import { FetchOptions } from '../strategies'; +import { EndpointParams, FetchOptions } from '../strategies'; -export type QueryProps

= { +export type QueryProps

= { path?: string; params?: Partial

; options?: Partial; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c9e97baa8..fac4a511b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -137,6 +137,18 @@ export type FetchStrategyCacheConfig = { cacheHandler?: FetchStrategyCacheHandler; }; +export type I18NConfig = { + locales: string[]; + defaultLocale: string; + + /** + * Whether HeadstartWP should try to detect the browser preferred locale + * + * @default true + */ + localeDetection?: boolean; +}; + export type HeadlessConfig = { host?: string; locale?: string; @@ -149,7 +161,7 @@ export type HeadlessConfig = { redirectStrategy?: RedirectStrategy; useWordPressPlugin?: boolean; integrations?: Integrations; - sites?: HeadlessConfig[]; + i18n?: I18NConfig; preview?: PreviewConfig; debug?: { requests?: boolean; @@ -157,4 +169,5 @@ export type HeadlessConfig = { devMode?: boolean; }; cache?: FetchStrategyCacheConfig; + sites?: Array>; }; diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts index 4846e9e62..0fa290091 100644 --- a/packages/core/src/utils/config.ts +++ b/packages/core/src/utils/config.ts @@ -32,6 +32,8 @@ export function getHeadstartWPConfig(): HeadlessConfig { debug, preview, cache, + locale, + i18n, } = __10up__HEADLESS_CONFIG; const defaultTaxonomies: CustomTaxonomies = [ @@ -73,6 +75,7 @@ export function getHeadstartWPConfig(): HeadlessConfig { : [...(customPostTypes || []), ...defaultPostTypes]; const headlessConfig = { + locale, sourceUrl, hostUrl: hostUrl || '', customPostTypes: postTypes, @@ -83,6 +86,7 @@ export function getHeadstartWPConfig(): HeadlessConfig { debug, preview, cache, + i18n, sites: (sites || []).map((site) => { // if host is not defined but hostUrl is, infer host from hostUrl if (typeof site.host === 'undefined' && typeof site.hostUrl !== 'undefined') { diff --git a/packages/next-redis-cache-provider/package.json b/packages/next-redis-cache-provider/package.json index 868494a5e..cbef6094d 100644 --- a/packages/next-redis-cache-provider/package.json +++ b/packages/next-redis-cache-provider/package.json @@ -32,7 +32,7 @@ "@types/node": "^18.15.11", "jest": "^29.0.3", "ts-jest": "^29.0.1", - "typescript": "^5.4.5", + "typescript": "^5.5.3", "ioredis-mock": "^8.9.0", "@types/ioredis-mock": "^8.2.5" }, diff --git a/packages/next/package.json b/packages/next/package.json index 3828c0ed7..5f1f24196 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -54,6 +54,8 @@ "lint": "eslint src" }, "dependencies": { + "negotiator": "^0.6.3", + "@formatjs/intl-localematcher": "^0.5.4", "deepmerge": "^4.3.1", "@headstartwp/core": "^1.4.4", "loader-utils": "^3.2.0", @@ -70,7 +72,7 @@ "next-router-mock": "^0.9.13", "node-mocks-http": "^1.14.1", "ts-jest": "^29.0.1", - "typescript": "^5.4.5", + "typescript": "^5.5.3", "isomorphic-fetch": "^3.0.0", "jest-fetch-mock": "^3.0.3", "tsc-esm-fix": "^2.20.27", diff --git a/packages/next/src/config/withHeadstartWPConfig.ts b/packages/next/src/config/withHeadstartWPConfig.ts index 8b4715462..23106075e 100644 --- a/packages/next/src/config/withHeadstartWPConfig.ts +++ b/packages/next/src/config/withHeadstartWPConfig.ts @@ -121,7 +121,7 @@ export function withHeadstartWPConfig( } }); - return { + const config: NextConfig = { ...nextConfig, images: { ...nextConfig.images, @@ -285,6 +285,17 @@ export function withHeadstartWPConfig( return config; }, }; + + // if i18n is sets + // but we are on pages router + // error it out! + if ((headlessConfig.i18n?.locales?.length ?? 0) > 0 && !isUsingAppRouter) { + throw new ConfigError( + 'The `i18n` option is not supported in the pages router. In the Pages router you must set the locales in the next config', + ); + } + + return config; } export function withHeadlessConfig( diff --git a/packages/next/src/middlewares/__tests__/appMiddleware.ts b/packages/next/src/middlewares/__tests__/appMiddleware.ts index 03c807296..670f8840d 100644 --- a/packages/next/src/middlewares/__tests__/appMiddleware.ts +++ b/packages/next/src/middlewares/__tests__/appMiddleware.ts @@ -1,6 +1,6 @@ import { NextRequest } from 'next/server'; import { setHeadstartWPConfig } from '@headstartwp/core/utils'; -import { AppMiddleware } from '../appMidleware'; +import { AppMiddleware, getAppRouterLocale } from '../appMidleware'; describe('appMiddleware', () => { it('adds headers', async () => { @@ -110,6 +110,7 @@ describe('appMiddleware', () => { 'http://test2.com/_sites/test.com/post-name', ); expect(res.headers.get('x-headstartwp-site')).toBe('test.com'); + expect(res.headers.get('x-headstartwp-locale')).toBe('en'); expect(res.headers.get('x-headstartwp-current-url')).toBe('/post-name'); }); @@ -156,7 +157,7 @@ describe('appMiddleware', () => { method: 'GET', nextConfig: { i18n: { - locales: ['zh', 'pr'], + locales: ['zh', 'pt-br'], defaultLocale: 'pt-br', }, }, @@ -167,7 +168,7 @@ describe('appMiddleware', () => { await expect(() => AppMiddleware(req)).rejects.toThrow('Site not found.'); }); - it('supports multisite in with App Router', async () => { + it('supports multisite with App Router', async () => { setHeadstartWPConfig({ sites: [ { @@ -196,7 +197,7 @@ describe('appMiddleware', () => { expect(res.headers.get('x-headstartwp-current-url')).toBe('/post-name'); }); - it('redirect /page/1 to page without 1', async () => { + it('redirect /page/1 to url without /page/1', async () => { setHeadstartWPConfig({ sourceUrl: 'http://testwp.com', }); @@ -248,4 +249,363 @@ describe('appMiddleware', () => { res = await AppMiddleware(req); expect(res.headers.get('x-headstartwp-current-url')).toBeNull(); }); + + it('[multisite] supports locales with App Router', async () => { + setHeadstartWPConfig({ + i18n: { + defaultLocale: 'en', + locales: ['en', 'pt'], + }, + sites: [ + { + sourceUrl: 'http://testwp.com', + hostUrl: 'http://test.com', + locale: 'en', + }, + { + sourceUrl: 'http://testwp2.com', + hostUrl: 'http://test2.com', + locale: 'pt', + }, + { + sourceUrl: 'http://testwp2.com/en', + hostUrl: 'http://test2.com', + locale: 'en', + }, + ], + }); + + let req = new NextRequest('http://test2.com/post-name', { + method: 'GET', + }); + + req.headers.set('host', 'test2.com'); + req.headers.set('accept-language', 'pt-BR'); + + let res = await AppMiddleware(req, { appRouter: true }); + + expect(getAppRouterLocale(req)).toStrictEqual(['en', 'pt']); + expect(res.status).toBe(307); + expect(res.headers.get('Location')).toBe('http://test2.com/pt/post-name'); + + // follow the redirect + req = new NextRequest('http://test2.com/pt/post-name', { + method: 'GET', + }); + + req.headers.set('host', 'test2.com'); + req.headers.set('accept-language', 'pt-BR'); + + res = await AppMiddleware(req, { appRouter: true }); + + expect(res.headers.get('x-middleware-rewrite')).toBe( + 'http://test2.com/pt/test2.com/post-name', + ); + expect(res.headers.get('x-headstartwp-site')).toBe('test2.com'); + expect(res.headers.get('x-headstartwp-locale')).toBe('pt'); + }); + + it('[polylang] supports locales with app router', async () => { + setHeadstartWPConfig({ + sourceUrl: 'http://testwp.com', + hostUrl: 'http://test.com', + integrations: { + polylang: { + enable: true, + }, + }, + i18n: { + locales: ['en', 'es'], + defaultLocale: 'en', + }, + }); + + let req = new NextRequest('http://test.com/post-name', { + method: 'GET', + }); + + req.headers.set('accept-language', 'es'); + + expect(getAppRouterLocale(req)).toStrictEqual(['en', 'es']); + + let res = await AppMiddleware(req, { appRouter: true }); + + expect(res.headers.get('x-headstartwp-locale')).toBe('es'); + // it should redirect + expect(res.status).toBe(307); + expect(res.headers.get('Location')).toBe('http://test.com/es/post-name'); + + req = new NextRequest('http://test.com/post-name', { + method: 'GET', + }); + + req.headers.set('accept-language', 'en'); + + res = await AppMiddleware(req, { appRouter: true }); + expect(res.headers.get('x-middleware-rewrite')).toBe('http://test.com/en/post-name'); + }); + + it('[polylang no locale detection] supports locales with app router', async () => { + setHeadstartWPConfig({ + sourceUrl: 'http://testwp.com', + hostUrl: 'http://test.com', + integrations: { + polylang: { + enable: true, + }, + }, + i18n: { + locales: ['en', 'es'], + defaultLocale: 'en', + localeDetection: false, + }, + }); + + let req = new NextRequest('http://test.com/post-name', { + method: 'GET', + }); + + req.headers.set('accept-language', 'es'); + + expect(getAppRouterLocale(req)).toStrictEqual(['en', 'en']); + + let res = await AppMiddleware(req, { appRouter: true }); + + expect(res.headers.get('x-headstartwp-locale')).toBe('en'); + expect(res.status).toBe(200); + + req = new NextRequest('http://test.com/post-name', { + method: 'GET', + }); + + req.headers.set('accept-language', 'en'); + + res = await AppMiddleware(req, { appRouter: true }); + expect(res.headers.get('x-middleware-rewrite')).toBe('http://test.com/en/post-name'); + }); + + it('[i18n] it does not cause redirect loops', async () => { + setHeadstartWPConfig({ + sourceUrl: 'http://testwp.com', + hostUrl: 'http://test.com', + integrations: { + polylang: { + enable: true, + }, + }, + i18n: { + locales: ['en', 'es'], + defaultLocale: 'en', + }, + }); + + let req = new NextRequest('http://test.com/post-name', { + method: 'GET', + }); + + req.headers.set('accept-language', 'es'); + + let res = await AppMiddleware(req, { appRouter: true }); + + expect(res.headers.get('x-headstartwp-locale')).toBe('es'); + // it should redirect + expect(res.status).toBe(307); + expect(res.headers.get('Location')).toBe('http://test.com/es/post-name'); + + req = new NextRequest('http://test.com/es/post-name', { + method: 'GET', + }); + + req.headers.set('accept-language', 'es'); + + res = await AppMiddleware(req, { appRouter: true }); + expect(res.status).toBe(200); + + req = new NextRequest('http://test.com/en/post-name', { + method: 'GET', + }); + + req.headers.set('accept-language', 'en'); + + res = await AppMiddleware(req, { appRouter: true }); + expect(res.headers.get('Location')).toBe('http://test.com/post-name'); + + req = new NextRequest('http://test.com/post-name', { + method: 'GET', + }); + + req.headers.set('accept-language', 'en'); + res = await AppMiddleware(req, { appRouter: true }); + expect(res.status).toBe(200); + }); + + it('[i18n] it skips locale detection if no locales array is set', async () => { + setHeadstartWPConfig({ + sourceUrl: 'http://testwp.com', + hostUrl: 'http://test.com', + integrations: { + polylang: { + enable: true, + }, + }, + // @ts-expect-error + i18n: { + defaultLocale: 'en', + }, + }); + + const req = new NextRequest('http://test.com/post-name', { + method: 'GET', + }); + + req.headers.set('accept-language', 'es'); + + expect(getAppRouterLocale(req)).toBeUndefined(); + }); + + it('throws on polylang and multisite together', async () => { + setHeadstartWPConfig({ + sourceUrl: 'http://testwp.com', + hostUrl: 'http://test.com', + integrations: { + polylang: { + enable: true, + }, + }, + sites: [ + { + sourceUrl: 'http://testwp.com', + hostUrl: 'http://test.com', + locale: 'en', + }, + { + sourceUrl: 'http://testwp2.com', + hostUrl: 'http://test2.com', + locale: 'pt', + }, + { + sourceUrl: 'http://testwp2.com/en', + hostUrl: 'http://test2.com', + locale: 'en', + }, + ], + }); + + const req = new NextRequest('http://test.com/post-name', { + method: 'GET', + }); + + req.headers.set('host', 'test.com'); + req.headers.set('accept-language', 'es'); + + expect(getAppRouterLocale(req)).toBeUndefined(); + + await expect(() => AppMiddleware(req, { appRouter: true })).rejects.toThrow( + 'Polylang and multisite are not supported together', + ); + }); + + it('[polylang] gets locale from url if set', async () => { + setHeadstartWPConfig({ + sourceUrl: 'http://testwp.com', + hostUrl: 'http://test.com', + integrations: { + polylang: { + enable: true, + }, + }, + i18n: { + locales: ['en', 'es'], + defaultLocale: 'en', + }, + }); + + let req = new NextRequest('http://test.com/en/post-name', { + method: 'GET', + }); + + req.headers.set('host', 'test.com'); + req.headers.set('accept-language', 'es'); + + expect(getAppRouterLocale(req)).toStrictEqual(['en', 'en']); + let res = await AppMiddleware(req, { appRouter: true }); + expect(res.headers.get('x-headstartwp-locale')).toBe('en'); + expect(res.headers.get('x-middleware-rewrite')).toBeNull(); + // should redirect from /en/post-name to /post-name + expect(res.status).toBe(307); + expect(res.headers.get('Location')).toBe('http://test.com/post-name'); + + // pt is a unsuported but valid + // the it should not redirect but just let it 404 + req = new NextRequest('http://test.com/pt/post-name', { + method: 'GET', + }); + + req.headers.set('host', 'test.com'); + req.headers.set('accept-language', 'es'); + + expect(getAppRouterLocale(req)).toStrictEqual(['en', 'pt']); + res = await AppMiddleware(req, { appRouter: true }); + expect(res.headers.get('x-headstartwp-locale')).toBe('pt'); + expect(res.headers.get('x-middleware-rewrite')).toBeNull(); + + expect(res.status).toBe(200); + }); + + it('[multisite with locale] gets locale from url if set', async () => { + setHeadstartWPConfig({ + i18n: { + locales: ['en', 'es'], + defaultLocale: 'en', + }, + sites: [ + { + sourceUrl: 'http://testwp.com', + hostUrl: 'http://test.com', + locale: 'en', + }, + { + sourceUrl: 'http://testwp2.com', + hostUrl: 'http://test2.com', + locale: 'pt', + }, + { + sourceUrl: 'http://testwp2.com/en', + hostUrl: 'http://test2.com', + locale: 'en', + }, + ], + }); + + // request for test2.com with en locale so should match `http://testwp2.com/en as sourceUrl` + let req = new NextRequest('http://test2.com/en/post-name', { + method: 'GET', + }); + + req.headers.set('host', 'test2.com'); + // this locale should be skipped since it isn't supported and there's a locale in the URL + req.headers.set('accept-language', 'es'); + + expect(getAppRouterLocale(req)).toStrictEqual(['en', 'en']); + const res = await AppMiddleware(req, { appRouter: true }); + expect(res.headers.get('x-headstartwp-locale')).toBe('en'); + expect(res.headers.get('x-middleware-rewrite')).toBeNull(); + // should redirect from /en/post-name to /post-name + expect(res.status).toBe(307); + expect(res.headers.get('Location')).toBe('http://test2.com/post-name'); + + // es is an unsuported but valid locale + req = new NextRequest('http://test2.com/es/post-name', { + method: 'GET', + }); + + req.headers.set('host', 'test2.com'); + req.headers.set('accept-language', 'pt'); + + expect(getAppRouterLocale(req)).toStrictEqual(['en', 'es']); + // TODO: should we 404 instead? + await expect(() => AppMiddleware(req, { appRouter: true })).rejects.toThrow( + 'Site not found', + ); + }); }); diff --git a/packages/next/src/middlewares/appMidleware.ts b/packages/next/src/middlewares/appMidleware.ts index b8fdf1a75..df0b0e3d2 100644 --- a/packages/next/src/middlewares/appMidleware.ts +++ b/packages/next/src/middlewares/appMidleware.ts @@ -1,6 +1,8 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { fetchRedirect, getHeadstartWPConfig, getSiteByHost } from '@headstartwp/core/utils'; +import Negotiator from 'negotiator'; +import { match as matchLocale } from '@formatjs/intl-localematcher'; const ALLOWED_STATIC_PATHS = /^\/.*\.(ico|png|jpg|jpeg)$/g; @@ -12,24 +14,113 @@ function isInternalRequest(req: NextRequest) { return req.nextUrl.pathname.startsWith('/_next'); } +function hasMultisiteConfig() { + const config = getHeadstartWPConfig(); + return (config.sites?.length ?? 0) > 0; +} + +function isPolylangIntegrationEnabled() { + const config = getHeadstartWPConfig(); + return config.integrations?.polylang?.enable ?? false; +} + +function isValidLocale(locale: string) { + try { + return Intl.getCanonicalLocales(locale).length > 0; + } catch (e) { + return false; + } +} + +function getAppRouterSupportedLocales() { + const config = getHeadstartWPConfig(); + + const { defaultLocale, locales = [], localeDetection = true } = config.i18n ?? {}; + + return { + defaultLocale, + supportedLocales: [...new Set(locales)], + localeDetection, + }; +} + +/** + * On App Router it returns a tuple with defaultLocale and locale + * + * @param request Next Request + * @returns A tuple [defaultLocale, locale] + */ +export function getAppRouterLocale(request: NextRequest): [string, string] | undefined { + const { defaultLocale, supportedLocales, localeDetection } = getAppRouterSupportedLocales(); + + if (typeof defaultLocale === 'undefined') { + return undefined; + } + + if (supportedLocales.length === 0) { + return undefined; + } + + // if there's a locale in the URL and it's a supported or valid locale, use it + const urlLocale = request.nextUrl.pathname.split('/')[1]; + if (supportedLocales.includes(urlLocale) || isValidLocale(urlLocale)) { + return [defaultLocale, urlLocale]; + } + + if (localeDetection) { + // Negotiator expects plain object so we need to transform headers + const negotiatorHeaders: Record = {}; + request.headers.forEach((value, key) => { + negotiatorHeaders[key] = value; + }); + + const locales: readonly string[] = [defaultLocale, ...(supportedLocales ?? [])]; + + // Use negotiator and intl-localematcher to get best locale + const languages = new Negotiator({ headers: negotiatorHeaders }).languages(locales); + + const locale = matchLocale(languages, locales, defaultLocale); + + return [defaultLocale, locale]; + } + + return [defaultLocale, defaultLocale]; +} + type AppMidlewareOptions = { appRouter: boolean; }; export async function AppMiddleware( req: NextRequest, - // eslint-disable-next-line @typescript-eslint/no-unused-vars options: AppMidlewareOptions = { appRouter: false }, ) { let response = NextResponse.next(); - const currentUrl = req.nextUrl.pathname; + const { pathname } = req.nextUrl; if (isStaticAssetRequest(req) || isInternalRequest(req)) { return response; } + const isPotentiallyMultisite = hasMultisiteConfig(); + const hasPolylangIntegration = isPolylangIntegrationEnabled(); + + if (hasPolylangIntegration && isPotentiallyMultisite && options.appRouter) { + // potentially conflicting set up + // will figure out later if we need to support this + throw new Error('Polylang and multisite are not supported together'); + } + + const [defaultAppRouterLocale, appRouterLocale] = options.appRouter + ? getAppRouterLocale(req) ?? [] + : []; + + const locale = options.appRouter && appRouterLocale ? appRouterLocale : req.nextUrl.locale; + const firstPathSlice = pathname.split('/')[1]; const hostname = req.headers.get('host') || ''; - const site = getSiteByHost(hostname, req.nextUrl.locale); + + // if it's polylang integration, we should not be using locale to get site + const site = getSiteByHost(hostname, !hasPolylangIntegration ? locale : undefined); const isMultisiteRequest = site !== null && typeof site.sourceUrl !== 'undefined'; const { @@ -39,11 +130,10 @@ export async function AppMiddleware( } = isMultisiteRequest ? site : getHeadstartWPConfig(); if (!sourceUrl) { + // todo: should we 404 instead? throw new Error('Site not found.'); } - const { pathname } = req.nextUrl; - if (redirectStrategy === 'always') { const redirect = await fetchRedirect(pathname, sourceUrl || ''); @@ -55,25 +145,71 @@ export async function AppMiddleware( } } - if (req.nextUrl.pathname.endsWith('/page/1') || req.nextUrl.pathname.endsWith('/page/1/')) { + if (pathname.endsWith('/page/1') || pathname.endsWith('/page/1/')) { return NextResponse.redirect(req.url.replace('/page/1', '')); } - if (isMultisiteRequest) { - const url = req.nextUrl; + let shouldRedirect = false; + + if (locale && options.appRouter) { + const { supportedLocales } = getAppRouterSupportedLocales(); + + const pathnameIsMissingLocale = supportedLocales.every( + (loc) => !pathname.startsWith(`/${loc}/`) && pathname !== `/${loc}`, + ); + + // redirect default locale in the URL to a version without the locale + // e.g /en/about-us -> /about-us where en is the default locale + if (locale === defaultAppRouterLocale && firstPathSlice === locale) { + shouldRedirect = true; + const pathNameWithoutLocale = pathname.replace(`/${locale}`, ''); + response = NextResponse.redirect( + new URL(pathNameWithoutLocale, req.url.replace(`/${locale}`, '')), + ); + } + // if we detected a non-default locale, there isn't a supported locale in the URL already + // but the first part of pathname (what is assumed to be a locale) is not a valid locale + // then we should redirect to add the locale + // e.g /about-us -> /en/about-us + else if ( + locale !== defaultAppRouterLocale && + pathnameIsMissingLocale && + !isValidLocale(firstPathSlice) + ) { + shouldRedirect = true; + response = NextResponse.redirect( + new URL(`/${locale}${pathname.startsWith('/') ? '' : '/'}${pathname}`, req.url), + ); + } + // nothing else and there's not a locale in path then rewrite to add default locale + else if (pathnameIsMissingLocale && !isValidLocale(firstPathSlice)) { + response = NextResponse.rewrite( + new URL( + `/${defaultAppRouterLocale}${pathname.startsWith('/') ? '' : '/'}${pathname}`, + req.url, + ), + ); + } + } + + if (isMultisiteRequest && !shouldRedirect) { + const pagesRouterRewrite = `/_sites/${hostname}${pathname}`; + const appRouterRewrite = locale + ? `/${locale}/${hostname}${pathname.replace(`/${locale}`, '')}` + : `/${hostname}${pathname}`; response = NextResponse.rewrite( - new URL( - options.appRouter - ? `/${hostname}${url.pathname}` - : `/_sites/${hostname}${url.pathname}`, - url, - ), + new URL(options.appRouter ? appRouterRewrite : pagesRouterRewrite, req.nextUrl), ); + response.headers.set('x-headstartwp-site', hostname); } - response.headers.set('x-headstartwp-current-url', currentUrl); + if (locale) { + response.headers.set('x-headstartwp-locale', locale); + } + + response.headers.set('x-headstartwp-current-url', pathname); return response; } diff --git a/packages/next/src/rsc/data/queries/__tests__/prepareQuery.ts b/packages/next/src/rsc/data/queries/__tests__/prepareQuery.ts index cbebfce97..5e1707cf9 100644 --- a/packages/next/src/rsc/data/queries/__tests__/prepareQuery.ts +++ b/packages/next/src/rsc/data/queries/__tests__/prepareQuery.ts @@ -1,4 +1,4 @@ -import { setHeadstartWPConfig } from '@headstartwp/core'; +import { getHeadstartWPConfig, setHeadstartWPConfig } from '@headstartwp/core'; import { prepareQuery } from '../prepareQuery'; describe('prepareQuery', () => { @@ -82,3 +82,96 @@ describe('prepareQuery', () => { ).toThrow('Sub site not found, make sure to add site3.com to headstartwp.config.js'); }); }); + +describe('prepareQuery with lang and multisite', () => { + beforeAll(() => { + setHeadstartWPConfig({ + sites: [ + { + sourceUrl: 'https://backend1.com', + hostUrl: 'https://site1.com', + locale: 'en', + }, + { + sourceUrl: 'https://backend2.com', + hostUrl: 'https://site1.com', + locale: 'pt', + }, + ], + }); + }); + + it('gets site correctly based on lang and host', () => { + expect( + prepareQuery({ + routeParams: { site: 'site1.com', lang: 'pt' }, + }), + ).toMatchObject({ + config: { + sourceUrl: 'https://backend2.com', + hostUrl: 'https://site1.com', + }, + }); + + expect( + prepareQuery({ + routeParams: { site: 'site1.com', lang: 'en' }, + }), + ).toMatchObject({ + config: { + sourceUrl: 'https://backend1.com', + hostUrl: 'https://site1.com', + }, + }); + }); +}); + +describe('prepareQuery with lang and polylang', () => { + beforeAll(() => { + setHeadstartWPConfig({ + sourceUrl: 'https://backend1.com', + hostUrl: 'https://site1.com', + integrations: { + polylang: { + enable: true, + }, + }, + i18n: { + locales: ['en', 'pt'], + defaultLocale: 'en', + }, + }); + }); + + it('prepares params correctly', () => { + expect( + prepareQuery( + { + routeParams: { lang: 'pt' }, + }, + getHeadstartWPConfig(), + ), + ).toMatchObject({ + config: { + sourceUrl: 'https://backend1.com', + hostUrl: 'https://site1.com', + }, + params: { + lang: 'pt', + }, + }); + }); + + it('throws for unsuported locales', () => { + expect(() => + prepareQuery( + { + routeParams: { lang: 'br' }, + }, + getHeadstartWPConfig(), + ), + ).toThrow( + 'Unsuported lang, make sure you add all desired locales to `config.i18n.locales`', + ); + }); +}); diff --git a/packages/next/src/rsc/data/queries/prepareQuery.ts b/packages/next/src/rsc/data/queries/prepareQuery.ts index cd6a68b48..9e1092ccb 100644 --- a/packages/next/src/rsc/data/queries/prepareQuery.ts +++ b/packages/next/src/rsc/data/queries/prepareQuery.ts @@ -1,4 +1,5 @@ import { + EndpointParams, FrameworkError, HeadlessConfig, getHeadstartWPConfig, @@ -11,15 +12,23 @@ import type { NextQueryProps } from './types'; const { all: merge } = deepmerge; -export function prepareQuery

( +export function prepareQuery

( query: NextQueryProps

, _config: HeadlessConfig | undefined = undefined, ) { - const { routeParams, handleError = true, ...rest } = query; + const { routeParams, handleError = true, params: originalParams, ...rest } = query; const path = routeParams?.path ?? ''; + const site = decodeURIComponent(routeParams?.site ?? ''); + + const rootConfig = getHeadstartWPConfig(); + const isPolylangEnabled = rootConfig.integrations?.polylang?.enable; + + // eslint-disable-next-line no-nested-ternary const siteConfig = routeParams?.site - ? getSiteByHost(decodeURIComponent(routeParams?.site)) + ? routeParams.lang && !isPolylangEnabled + ? getSiteByHost(site, routeParams.lang) + : getSiteByHost(site) : null; if (routeParams?.site && !siteConfig) { @@ -37,11 +46,25 @@ export function prepareQuery

( rest.options ?? {}, ]); - const config = siteConfig ?? _config; + const config = siteConfig ?? (_config || rootConfig); const pathname = Array.isArray(path) ? convertToPath(path) : path; + const params: typeof originalParams = + typeof originalParams !== 'undefined' ? { ...originalParams } : {}; + + if (routeParams?.lang && isPolylangEnabled) { + const supportedLocales = rootConfig.i18n?.locales ?? []; + if (!supportedLocales.includes(routeParams.lang)) { + throw new FrameworkError( + 'Unsuported lang, make sure you add all desired locales to `config.i18n.locales`', + ); + } + params.lang = routeParams.lang; + } + return { ...rest, + params, options, path: pathname, config: config ?? getHeadstartWPConfig(), diff --git a/packages/next/src/rsc/data/queries/types.ts b/packages/next/src/rsc/data/queries/types.ts index 41452dff8..120c75362 100644 --- a/packages/next/src/rsc/data/queries/types.ts +++ b/packages/next/src/rsc/data/queries/types.ts @@ -1,9 +1,10 @@ -import type { QueryProps } from '@headstartwp/core'; +import type { EndpointParams, QueryProps } from '@headstartwp/core'; -export type NextQueryProps

= { +export type NextQueryProps

= { routeParams?: { path?: string | string[]; site?: string; + lang?: string; [k: string]: unknown; }; handleError?: boolean; diff --git a/packages/next/src/rsc/types.ts b/packages/next/src/rsc/types.ts index 7b4ebcbe0..e18dce76f 100644 --- a/packages/next/src/rsc/types.ts +++ b/packages/next/src/rsc/types.ts @@ -1,8 +1,8 @@ export type HeadstartWPRoute = { - params: { path: string[]; site?: string }; + params: { path: string[]; site?: string; lang?: string }; } & Params; export type HeadstartWPLayout = { - params: { site?: string }; + params: { site?: string; lang?: string }; children: React.ReactNode; } & Params; diff --git a/projects/wp-multisite-i18n-nextjs/.env.trunk b/projects/wp-multisite-i18n-nextjs/.env.trunk deleted file mode 100644 index e69de29bb..000000000 diff --git a/projects/wp-multisite-i18n-nextjs/babel.config.js b/projects/wp-multisite-i18n-nextjs/babel.config.js deleted file mode 100644 index 2675b8c62..000000000 --- a/projects/wp-multisite-i18n-nextjs/babel.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: ['next/babel'], -}; diff --git a/projects/wp-multisite-i18n-nextjs/headless.config.js b/projects/wp-multisite-i18n-nextjs/headstartwp.config.js similarity index 100% rename from projects/wp-multisite-i18n-nextjs/headless.config.js rename to projects/wp-multisite-i18n-nextjs/headstartwp.config.js diff --git a/projects/wp-multisite-i18n-nextjs/next.config.js b/projects/wp-multisite-i18n-nextjs/next.config.js index e30be1210..e481b81e0 100644 --- a/projects/wp-multisite-i18n-nextjs/next.config.js +++ b/projects/wp-multisite-i18n-nextjs/next.config.js @@ -1,11 +1,9 @@ -const { withHeadlessConfig } = require('@headstartwp/next/config'); +const { withHeadstartWPConfig } = require('@headstartwp/next/config'); const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); -const headlessConfig = require('./headless.config'); - /** * Update whatever you need within the nextConfig object. * @@ -22,4 +20,4 @@ const nextConfig = { }, }; -module.exports = withBundleAnalyzer(withHeadlessConfig(nextConfig, headlessConfig)); +module.exports = withBundleAnalyzer(withHeadstartWPConfig(nextConfig)); diff --git a/projects/wp-multisite-i18n-nextjs/src/components/Header/Nav.js b/projects/wp-multisite-i18n-nextjs/src/components/Header/Nav.js index fcf19d9a7..7b281f805 100644 --- a/projects/wp-multisite-i18n-nextjs/src/components/Header/Nav.js +++ b/projects/wp-multisite-i18n-nextjs/src/components/Header/Nav.js @@ -33,12 +33,7 @@ const navStyles = css` `; export const Nav = () => { - const { data, loading, error } = useMenu('primary', { - // these settings will re-render menu client side to ensure - // it always have the latest items - revalidateOnMount: true, - revalidateOnFocus: true, - }); + const { data, loading, error } = useMenu('primary'); if (loading || error) { return null; diff --git a/projects/wp-nextjs/package.json b/projects/wp-nextjs/package.json index 6c2ed47c2..466d88b7b 100644 --- a/projects/wp-nextjs/package.json +++ b/projects/wp-nextjs/package.json @@ -33,7 +33,7 @@ "@next/bundle-analyzer": "^12.1.0", "@typescript-eslint/eslint-plugin": "^7.9.0", "@typescript-eslint/parser": "^7.9.0", - "typescript": "^5.4.5", + "typescript": "^5.5.3", "@types/nprogress": "^0.2.3", "@types/react": "^18", "@types/react-dom": "^18" diff --git a/projects/wp-polylang-nextjs-app/.env b/projects/wp-polylang-nextjs-app/.env new file mode 100644 index 000000000..b013281ea --- /dev/null +++ b/projects/wp-polylang-nextjs-app/.env @@ -0,0 +1,2 @@ +NEXT_PUBLIC_HEADLESS_WP_URL=https://js1.10up.com +NEXT_PUBLIC_HOST_URL=https://js1.10up.com \ No newline at end of file diff --git a/projects/wp-polylang-nextjs-app/.eslintrc.js b/projects/wp-polylang-nextjs-app/.eslintrc.js new file mode 100644 index 000000000..16b1cc13e --- /dev/null +++ b/projects/wp-polylang-nextjs-app/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + extends: ['@10up/eslint-config/react'], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + settings: { + jsdoc: { + mode: 'typescript', + }, + }, + rules: { + 'react/require-default-props': ['error', { functions: 'defaultArguments' }], + 'jsdoc/require-returns-type': 'off', + }, +}; diff --git a/projects/wp-polylang-nextjs-app/.gitignore b/projects/wp-polylang-nextjs-app/.gitignore new file mode 100644 index 000000000..fd3dbb571 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/projects/wp-polylang-nextjs-app/README.md b/projects/wp-polylang-nextjs-app/README.md new file mode 100644 index 000000000..c4033664f --- /dev/null +++ b/projects/wp-polylang-nextjs-app/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/projects/wp-polylang-nextjs-app/headstartwp.config.js b/projects/wp-polylang-nextjs-app/headstartwp.config.js new file mode 100644 index 000000000..3a23b5906 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/headstartwp.config.js @@ -0,0 +1,22 @@ +/** + * Headless Config + * + * @type {import('@headstartwp/core').HeadlessConfig} + */ +module.exports = { + useWordPressPlugin: true, + sourceUrl: process.env.NEXT_PUBLIC_HEADLESS_WP_URL, + hostUrl: process.env.NEXT_PUBLIC_HOST_URL, + preview: { + usePostLinkForRedirect: true, + }, + i18n: { + locales: ['en', 'pt', 'es'], + defaultLocale: 'en', + }, + integrations: { + polylang: { + enable: true, + }, + }, +}; diff --git a/projects/wp-polylang-nextjs-app/next.config.js b/projects/wp-polylang-nextjs-app/next.config.js new file mode 100644 index 000000000..bf74d4ecd --- /dev/null +++ b/projects/wp-polylang-nextjs-app/next.config.js @@ -0,0 +1,22 @@ +const { withHeadstartWPConfig } = require('@headstartwp/next/config'); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + webpack: (config) => { + // TODO: figure out why this is needed + config.resolve = { + ...config.resolve, + conditionNames: ['import'], + }; + + return config; + }, + + logging: { + fetches: { + fullUrl: true, + }, + }, +}; + +module.exports = withHeadstartWPConfig(nextConfig); diff --git a/projects/wp-polylang-nextjs-app/package.json b/projects/wp-polylang-nextjs-app/package.json new file mode 100644 index 000000000..9e01db510 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/package.json @@ -0,0 +1,27 @@ +{ + "name": "@10up/wp-polylang-nextjs-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "react": "^18", + "react-dom": "^18", + "next": "14.2.3", + "@headstartwp/core": "^1.4.3", + "@headstartwp/next": "^1.4.2" + }, + "devDependencies": { + "@10up/eslint-config": "^4.0.0", + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.3" + } +} diff --git a/projects/wp-polylang-nextjs-app/src/app/[lang]/(single)/[...path]/page.tsx b/projects/wp-polylang-nextjs-app/src/app/[lang]/(single)/[...path]/page.tsx new file mode 100644 index 000000000..4fd359fe4 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/[lang]/(single)/[...path]/page.tsx @@ -0,0 +1,28 @@ +import { BlocksRenderer, HtmlDecoder } from '@headstartwp/core/react'; +import { HeadstartWPRoute, queryPost } from '@headstartwp/next/app'; + +const Single = async ({ params }: HeadstartWPRoute) => { + const { data } = await queryPost({ + routeParams: params, + params: { + postType: ['post', 'page'], + }, + options: { + headers: { + cache: 'force-cache', + }, + }, + }); + + return ( +

+

+ +

+ + +
+ ); +}; + +export default Single; diff --git a/projects/wp-polylang-nextjs-app/src/app/[lang]/author/[...path]/page.tsx b/projects/wp-polylang-nextjs-app/src/app/[lang]/author/[...path]/page.tsx new file mode 100644 index 000000000..08eec52ea --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/[lang]/author/[...path]/page.tsx @@ -0,0 +1,24 @@ +import { HeadstartWPRoute, queryAuthorArchive } from '@headstartwp/next/app'; +import Link from 'next/link'; + +const AuthorArchive = async ({ params }: HeadstartWPRoute) => { + const { data } = await queryAuthorArchive({ + routeParams: params, + }); + + return ( +
+

{data.queriedObject.author?.name}

+ + +
+ ); +}; + +export default AuthorArchive; diff --git a/projects/wp-polylang-nextjs-app/src/app/[lang]/blog/[[...path]]/page.tsx b/projects/wp-polylang-nextjs-app/src/app/[lang]/blog/[[...path]]/page.tsx new file mode 100644 index 000000000..f4aa233e0 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/[lang]/blog/[[...path]]/page.tsx @@ -0,0 +1,61 @@ +import { PostEntity, QueriedObject } from '@headstartwp/core'; +import { HeadstartWPRoute, queryPostOrPosts } from '@headstartwp/next/app'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +type ArchiveProps = { + posts: PostEntity[]; + queriedObject: QueriedObject; +}; + +const Archive = ({ posts, queriedObject }: ArchiveProps) => { + return ( +
+

{queriedObject.term?.name}

+ + +
+ ); +}; + +const BlogPage = async ({ params }: HeadstartWPRoute) => { + const { isArchive, isSingle, data } = await queryPostOrPosts({ + routeParams: params, + params: { + single: { + postType: 'post', + }, + archive: { + postType: 'post', + /** + * Specifying the _fields param reduces the amount of data queried and returned by the API. + */ + _fields: ['id', 'title', 'link'], + }, + priority: 'single', + routeMatchStrategy: 'single', + }, + }); + + if (isArchive && typeof data.posts !== 'undefined') { + return ; + } + + if (isSingle && typeof data.post !== 'undefined') { + return ( +
+

{data.post.title.rendered}

+
+ ); + } + + return notFound(); +}; + +export default BlogPage; diff --git a/projects/wp-polylang-nextjs-app/src/app/[lang]/category/[...path]/page.tsx b/projects/wp-polylang-nextjs-app/src/app/[lang]/category/[...path]/page.tsx new file mode 100644 index 000000000..8538d57f1 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/[lang]/category/[...path]/page.tsx @@ -0,0 +1,27 @@ +import { HeadstartWPRoute, queryPosts } from '@headstartwp/next/app'; +import Link from 'next/link'; + +const CategoryArchive = async ({ params }: HeadstartWPRoute) => { + const { data } = await queryPosts({ + routeParams: params, + params: { + taxonomy: 'category', + }, + }); + + return ( +
+

{data.queriedObject.term?.name}

+ + +
+ ); +}; + +export default CategoryArchive; diff --git a/projects/wp-polylang-nextjs-app/src/app/[lang]/favicon.ico b/projects/wp-polylang-nextjs-app/src/app/[lang]/favicon.ico new file mode 100644 index 000000000..718d6fea4 Binary files /dev/null and b/projects/wp-polylang-nextjs-app/src/app/[lang]/favicon.ico differ diff --git a/projects/wp-polylang-nextjs-app/src/app/[lang]/globals.css b/projects/wp-polylang-nextjs-app/src/app/[lang]/globals.css new file mode 100644 index 000000000..687196d61 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/[lang]/globals.css @@ -0,0 +1,34 @@ +.form-container { + position: fixed; + bottom: 20px; + right: 20px; + background-color: #f1f1f1; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +.form-container form { + display: flex; + flex-direction: column; +} + +.form-container input { + margin-bottom: 10px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 3px; +} + +.form-container button { + padding: 10px; + background-color: #4CAF50; + color: white; + border: none; + border-radius: 3px; + cursor: pointer; +} + +.form-container button:hover { + background-color: #45a049; +} diff --git a/projects/wp-polylang-nextjs-app/src/app/[lang]/layout.tsx b/projects/wp-polylang-nextjs-app/src/app/[lang]/layout.tsx new file mode 100644 index 000000000..0501c9c14 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/[lang]/layout.tsx @@ -0,0 +1,19 @@ +import { HeadstartWPLayout, PreviewIndicator, queryAppSettings } from '@headstartwp/next/app'; +import { Menu, SettingsProvider, ThemeSettingsProvider } from '@headstartwp/core/react'; +import { getHeadstartWPConfig } from '@headstartwp/core'; + +const RootLayout = async ({ children, params }: Readonly) => { + const { menu, data } = await queryAppSettings({ menu: 'primary', routeParams: params }); + + return ( + + + {menu ? : null} + {children} + + + + ); +}; + +export default RootLayout; diff --git a/projects/wp-polylang-nextjs-app/src/app/[lang]/not-found.tsx b/projects/wp-polylang-nextjs-app/src/app/[lang]/not-found.tsx new file mode 100644 index 000000000..0b6d8855c --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/[lang]/not-found.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link'; + +const NotFound = () => { + return ( +
+

Not Found

+

Could not find requested resource

+ Return Home +
+ ); +}; + +export default NotFound; diff --git a/projects/wp-polylang-nextjs-app/src/app/[lang]/page.tsx b/projects/wp-polylang-nextjs-app/src/app/[lang]/page.tsx new file mode 100644 index 000000000..712e3efc7 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/[lang]/page.tsx @@ -0,0 +1,28 @@ +import { BlocksRenderer } from '@headstartwp/core/react'; +import { HeadstartWPRoute, queryAppSettings, queryPost } from '@headstartwp/next/app'; + +const Home = async ({ params }: HeadstartWPRoute) => { + const { + data: { home }, + } = await queryAppSettings({ + routeParams: params, + }); + + const { data } = await queryPost({ + routeParams: params, + params: { + slug: home.slug ?? 'front-page', + postType: 'page', + }, + }); + + return ( +
+
+ +
+
+ ); +}; + +export default Home; diff --git a/projects/wp-polylang-nextjs-app/src/app/[lang]/search/[[...path]]/page.tsx b/projects/wp-polylang-nextjs-app/src/app/[lang]/search/[[...path]]/page.tsx new file mode 100644 index 000000000..aa29ec914 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/[lang]/search/[[...path]]/page.tsx @@ -0,0 +1,29 @@ +import { HeadstartWPRoute, querySearch } from '@headstartwp/next/app'; +import Link from 'next/link'; + +const Search = async ({ params }: HeadstartWPRoute) => { + const { data } = await querySearch({ + routeParams: params, + }); + + if (data.pageInfo.totalItems === 0) { + return 'Nothing found'; + } + + return ( + <> +

Search Results

+
    + {data.searchResults.map((item) => ( +
  • + + {item.id} - {item.title} + +
  • + ))} +
+ + ); +}; + +export default Search; diff --git a/projects/wp-polylang-nextjs-app/src/app/[lang]/tag/[...path]/page.tsx b/projects/wp-polylang-nextjs-app/src/app/[lang]/tag/[...path]/page.tsx new file mode 100644 index 000000000..283091063 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/[lang]/tag/[...path]/page.tsx @@ -0,0 +1,27 @@ +import { HeadstartWPRoute, queryPosts } from '@headstartwp/next/app'; +import Link from 'next/link'; + +const TagArchive = async ({ params }: HeadstartWPRoute) => { + const { data } = await queryPosts({ + routeParams: params, + params: { + taxonomy: 'post_tag', + }, + }); + + return ( +
+

{data.queriedObject.term?.name}

+ +
    + {data.posts.map((post) => ( +
  • + {post.title.rendered} +
  • + ))} +
+
+ ); +}; + +export default TagArchive; diff --git a/projects/wp-polylang-nextjs-app/src/app/api/preview/route.ts b/projects/wp-polylang-nextjs-app/src/app/api/preview/route.ts new file mode 100644 index 000000000..ce26916d5 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/api/preview/route.ts @@ -0,0 +1,7 @@ +import { previewRouteHandler } from '@headstartwp/next/app'; +import type { NextRequest } from 'next/server'; + +export async function GET(request: NextRequest) { + // @ts-expect-error + return previewRouteHandler(request); +} diff --git a/projects/wp-polylang-nextjs-app/src/app/api/revalidate/route.ts b/projects/wp-polylang-nextjs-app/src/app/api/revalidate/route.ts new file mode 100644 index 000000000..dc1dc3548 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/api/revalidate/route.ts @@ -0,0 +1,7 @@ +import { revalidateRouteHandler } from '@headstartwp/next/app'; +import type { NextRequest } from 'next/server'; + +export async function GET(request: NextRequest) { + // @ts-expect-error + return revalidateRouteHandler(request); +} diff --git a/projects/wp-polylang-nextjs-app/src/app/layout.tsx b/projects/wp-polylang-nextjs-app/src/app/layout.tsx new file mode 100644 index 000000000..36d26353e --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata: Metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +}; + +const RootLayout = async ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return ( + + {children} + + ); +}; + +export default RootLayout; diff --git a/projects/wp-polylang-nextjs-app/src/app/not-found.tsx b/projects/wp-polylang-nextjs-app/src/app/not-found.tsx new file mode 100644 index 000000000..0b6d8855c --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/app/not-found.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link'; + +const NotFound = () => { + return ( +
+

Not Found

+

Could not find requested resource

+ Return Home +
+ ); +}; + +export default NotFound; diff --git a/projects/wp-polylang-nextjs-app/src/middleware.ts b/projects/wp-polylang-nextjs-app/src/middleware.ts new file mode 100644 index 000000000..f73062bbd --- /dev/null +++ b/projects/wp-polylang-nextjs-app/src/middleware.ts @@ -0,0 +1,20 @@ +import { AppMiddleware } from '@headstartwp/next/middlewares'; +import { NextRequest } from 'next/server'; + +export const config = { + matcher: [ + /* + * Match all paths except for: + * 1. /api routes + * 2. /_next (Next.js internals) + * 3. /fonts (inside /public) + * 4. all root files inside /public (e.g. /favicon.ico) + */ + '/((?!api|cache-healthcheck|_next|fonts[\\w-]+\\.\\w+).*)', + ], +}; + +export async function middleware(req: NextRequest) { + // @ts-expect-error + return AppMiddleware(req, { appRouter: true }); +} diff --git a/projects/wp-polylang-nextjs-app/tsconfig.json b/projects/wp-polylang-nextjs-app/tsconfig.json new file mode 100644 index 000000000..0471d5fc7 --- /dev/null +++ b/projects/wp-polylang-nextjs-app/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ], + "forceConsistentCasingInFileNames": true + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +}