diff --git a/.changeset/trac-282-custom-locale-subfolders.md b/.changeset/trac-282-custom-locale-subfolders.md new file mode 100644 index 0000000000..393bf5c3d4 --- /dev/null +++ b/.changeset/trac-282-custom-locale-subfolders.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Consume merchant-configured per-locale URL subfolders from the BigCommerce Storefront GraphQL API (`Locale.path`) and route URLs accordingly. Treats `path: ""` as an explicit request to serve at the root and `path: null` as unconfigured (legacy fallback to the locale code). The root slot is claimed by the default locale when its path is empty or null; otherwise the first non-default locale with an empty path takes it. Locales without a custom path fall back to `/${code}` as before. diff --git a/core/build-config/schema.ts b/core/build-config/schema.ts index a2c802d0c7..710d49148c 100644 --- a/core/build-config/schema.ts +++ b/core/build-config/schema.ts @@ -5,6 +5,7 @@ export const buildConfigSchema = z.object({ z.object({ code: z.string(), isDefault: z.boolean(), + path: z.string().nullable(), }), ), urls: z.object({ diff --git a/core/i18n/locales.ts b/core/i18n/locales.ts index b9cde9ccb9..b3cf272e43 100644 --- a/core/i18n/locales.ts +++ b/core/i18n/locales.ts @@ -3,4 +3,40 @@ import { buildConfig } from '~/build-config/reader'; const localeNodes = buildConfig.get('locales'); export const locales = localeNodes.map((locale) => locale.code); -export const defaultLocale = localeNodes.find((locale) => locale.isDefault)?.code ?? 'en'; + +const defaultNode = localeNodes.find((locale) => locale.isDefault); + +export const defaultLocale = defaultNode?.code ?? 'en'; + +// A locale's path is "empty" (wants root, no prefix) when it's null or "". +const isEmptyPath = (path: string | null) => path === null || path === ''; + +// Only one locale can serve at the root (`/`). The default locale wins; if the default +// has a custom path, the first non-default with an empty path takes the root. +const rootLocale: string | undefined = (() => { + if (defaultNode && isEmptyPath(defaultNode.path)) return defaultNode.code; + + return localeNodes.find((locale) => !locale.isDefault && isEmptyPath(locale.path))?.code; +})(); + +// True when the merchant has configured at least one non-empty custom subfolder. +export const hasCustomSubfolders = localeNodes.some( + (locale) => locale.path !== null && locale.path !== '', +); + +// Prefix map for every locale that is NOT at the root. Locales with a non-empty `path` +// use that path; everyone else falls back to `/${code}`. +export const prefixes = localeNodes.reduce>((acc, locale) => { + if (locale.code === rootLocale) return acc; + + acc[locale.code] = locale.path ? `/${locale.path}` : `/${locale.code}`; + + return acc; +}, {}); + +// next-intl couples the "no-prefix" URL slot to its `defaultLocale` in `as-needed` mode. +// When the merchant put a non-default locale at the root, route under that locale here +// while keeping `defaultLocale` (above) as the CP-configured default for content/fallback. +export const routingDefaultLocale = rootLocale ?? defaultLocale; + +export const hasRootLocale = rootLocale !== undefined; diff --git a/core/i18n/routing.ts b/core/i18n/routing.ts index 60517b1884..e250bd9eb1 100644 --- a/core/i18n/routing.ts +++ b/core/i18n/routing.ts @@ -1,21 +1,28 @@ import { createNavigation } from 'next-intl/navigation'; import { defineRouting } from 'next-intl/routing'; -import { defaultLocale, locales } from './locales'; +import { defaultLocale, hasRootLocale, locales, prefixes, routingDefaultLocale } from './locales'; + +export { defaultLocale }; enum LocalePrefixes { ALWAYS = 'always', - // Don't use NEVER as there is a issue that causes cache problems and returns the wrong messages. + // Don't use NEVER as there is an issue that causes cache problems and returns the wrong messages. // More info: https://github.com/amannn/next-intl/issues/786 // NEVER = 'never', ASNEEDED = 'as-needed', // removes prefix on default locale } -const localePrefix = LocalePrefixes.ASNEEDED; +// When a locale claims the root URL (either the CP default with empty/null path, or a +// non-default with an explicit empty path), use `as-needed` mode under that locale so +// it serves at `/`. Otherwise every locale gets an explicit prefix. +const localePrefix = hasRootLocale + ? { mode: LocalePrefixes.ASNEEDED, prefixes } + : { mode: LocalePrefixes.ALWAYS, prefixes }; export const routing = defineRouting({ locales, - defaultLocale, + defaultLocale: routingDefaultLocale, localePrefix, }); diff --git a/core/lib/seo/canonical.ts b/core/lib/seo/canonical.ts index dd1573947a..c924b832f6 100644 --- a/core/lib/seo/canonical.ts +++ b/core/lib/seo/canonical.ts @@ -3,7 +3,7 @@ import { cache } from 'react'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; -import { defaultLocale, locales } from '~/i18n/locales'; +import { defaultLocale, locales, prefixes } from '~/i18n/locales'; interface CanonicalUrlOptions { /** @@ -90,7 +90,11 @@ function buildLocalizedUrl(baseUrl: string, pathname: string, locale: string): s const url = new URL(pathname, baseUrl); - url.pathname = locale === defaultLocale ? url.pathname : `/${locale}${url.pathname}`; + // The root locale (CP default at root, or non-default with explicit empty path) has no prefix; + // every other locale uses its CP-configured path or falls back to its locale code. + const prefix = prefixes[locale]; + + url.pathname = prefix ? `${prefix}${url.pathname}` : url.pathname; if (trailingSlash && !url.pathname.endsWith('/')) { url.pathname += '/'; diff --git a/core/next.config.ts b/core/next.config.ts index 1f39548d60..afe17e2eb8 100644 --- a/core/next.config.ts +++ b/core/next.config.ts @@ -25,6 +25,7 @@ const SettingsQuery = graphql(` locales { code isDefault + path } } } diff --git a/core/proxies/with-routes.ts b/core/proxies/with-routes.ts index 2be0083849..bf1ec6d60d 100644 --- a/core/proxies/with-routes.ts +++ b/core/proxies/with-routes.ts @@ -5,6 +5,7 @@ import { auth } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; +import { prefixes } from '~/i18n/locales'; import { getVisitIdCookie, getVisitorIdCookie } from '~/lib/analytics/bigcommerce'; import { sendProductViewedEvent } from '~/lib/analytics/bigcommerce/data-events'; import { kvKey, STORE_STATUS_KEY } from '~/lib/kv/keys'; @@ -218,12 +219,17 @@ const updateStatusCache = async ( }; const clearLocaleFromPath = (path: string, locale: string) => { - if (path === `/${locale}` || path === `/${locale}/`) { + // The root locale has no prefix in the URL; there's nothing to strip. + const prefix = prefixes[locale]; + + if (!prefix) return path; + + if (path === prefix || path === `${prefix}/`) { return '/'; } - if (path.startsWith(`/${locale}/`)) { - return path.replace(`/${locale}`, ''); + if (path.startsWith(`${prefix}/`)) { + return path.replace(prefix, ''); } return path;