Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/trac-282-custom-locale-subfolders.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions core/build-config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const buildConfigSchema = z.object({
z.object({
code: z.string(),
isDefault: z.boolean(),
path: z.string().nullable(),
}),
),
urls: z.object({
Expand Down
38 changes: 37 additions & 1 deletion core/i18n/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, `/${string}`>>((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;
15 changes: 11 additions & 4 deletions core/i18n/routing.ts
Original file line number Diff line number Diff line change
@@ -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,
});

Expand Down
8 changes: 6 additions & 2 deletions core/lib/seo/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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 += '/';
Expand Down
1 change: 1 addition & 0 deletions core/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const SettingsQuery = graphql(`
locales {
code
isDefault
path
}
}
}
Expand Down
12 changes: 9 additions & 3 deletions core/proxies/with-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Loading