diff --git a/.changeset/cuddly-boats-teach.md b/.changeset/cuddly-boats-teach.md new file mode 100644 index 0000000000..a95e31f698 --- /dev/null +++ b/.changeset/cuddly-boats-teach.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +fetch available locales at build time diff --git a/core/.gitignore b/core/.gitignore index d96c1399f9..26db3633a1 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -43,3 +43,6 @@ client/generated # secrets .catalyst + +# Build config +build-config.json diff --git a/core/app/[locale]/(default)/(auth)/login/page.tsx b/core/app/[locale]/(default)/(auth)/login/page.tsx index c0ff3232fb..cd428bf835 100644 --- a/core/app/[locale]/(default)/(auth)/login/page.tsx +++ b/core/app/[locale]/(default)/(auth)/login/page.tsx @@ -2,7 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Link } from '~/components/link'; import { Button } from '~/components/ui/button'; -import { locales, LocaleType } from '~/i18n/routing'; +import { locales } from '~/i18n/routing'; import { LoginForm } from './_components/login-form'; @@ -19,7 +19,7 @@ export async function generateMetadata({ params }: Props) { } interface Props { - params: Promise<{ locale: LocaleType }>; + params: Promise<{ locale: string }>; } export default async function Login({ params }: Props) { diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx index 34ac35e885..8abd438b28 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -4,7 +4,6 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import { ProductCard } from '~/components/product-card'; import { Pagination } from '~/components/ui/pagination'; -import { LocaleType } from '~/i18n/routing'; import { FacetedSearch } from '../../_components/faceted-search'; import { MobileSideNav } from '../../_components/mobile-side-nav'; @@ -16,7 +15,7 @@ import { getBrand } from './page-data'; interface Props { params: Promise<{ slug: string; - locale: LocaleType; + locale: string; }>; searchParams: Promise>; } diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index 53c3914300..28905b1a91 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -5,7 +5,6 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Breadcrumbs } from '~/components/breadcrumbs'; import { ProductCard } from '~/components/product-card'; import { Pagination } from '~/components/ui/pagination'; -import { LocaleType } from '~/i18n/routing'; import { FacetedSearch } from '../../_components/faceted-search'; import { MobileSideNav } from '../../_components/mobile-side-nav'; @@ -20,7 +19,7 @@ import { getCategoryPageData } from './page-data'; interface Props { params: Promise<{ slug: string; - locale: LocaleType; + locale: string; }>; searchParams: Promise>; } diff --git a/core/app/[locale]/(default)/account/(tabs)/layout.tsx b/core/app/[locale]/(default)/account/(tabs)/layout.tsx index 99a0e27863..74862f7f1d 100644 --- a/core/app/[locale]/(default)/account/(tabs)/layout.tsx +++ b/core/app/[locale]/(default)/account/(tabs)/layout.tsx @@ -1,12 +1,10 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import { PropsWithChildren } from 'react'; -import { LocaleType } from '~/i18n/routing'; - import { TabNavigation, TabType } from './_components/tab-navigation'; interface Props extends PropsWithChildren { - params: Promise<{ locale: LocaleType; tab?: TabType }>; + params: Promise<{ locale: string; tab?: TabType }>; } export default async function AccountTabLayout({ children, params }: Props) { diff --git a/core/app/[locale]/(default)/account/(tabs)/settings/change-password/page.tsx b/core/app/[locale]/(default)/account/(tabs)/settings/change-password/page.tsx index b3704f5999..e9d7190e11 100644 --- a/core/app/[locale]/(default)/account/(tabs)/settings/change-password/page.tsx +++ b/core/app/[locale]/(default)/account/(tabs)/settings/change-password/page.tsx @@ -1,7 +1,5 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; -import { LocaleType } from '~/i18n/routing'; - import { TabHeading } from '../../_components/tab-heading'; import { ChangePasswordForm } from './_components/change-password-form'; @@ -15,7 +13,7 @@ export async function generateMetadata() { } interface Props { - params: Promise<{ locale: LocaleType }>; + params: Promise<{ locale: string }>; } export default async function ChangePassword({ params }: Props) { diff --git a/core/app/[locale]/(default)/blog/page.tsx b/core/app/[locale]/(default)/blog/page.tsx index a8bc103902..be7d9dc068 100644 --- a/core/app/[locale]/(default)/blog/page.tsx +++ b/core/app/[locale]/(default)/blog/page.tsx @@ -4,12 +4,11 @@ import { getTranslations } from 'next-intl/server'; import { BlogPostCard } from '~/components/blog-post-card'; import { Pagination } from '~/components/ui/pagination'; -import { LocaleType } from '~/i18n/routing'; import { getBlogPosts } from './page-data'; interface Props { - params: Promise<{ locale: LocaleType }>; + params: Promise<{ locale: string }>; searchParams: Promise>; } diff --git a/core/app/[locale]/(default)/layout.tsx b/core/app/[locale]/(default)/layout.tsx index 62b74a5e8b..a1dc8354ac 100644 --- a/core/app/[locale]/(default)/layout.tsx +++ b/core/app/[locale]/(default)/layout.tsx @@ -4,10 +4,9 @@ import { PropsWithChildren, Suspense } from 'react'; import { Footer } from '~/components/footer/footer'; import { Header, HeaderSkeleton } from '~/components/header'; import { Cart } from '~/components/header/cart'; -import { LocaleType } from '~/i18n/routing'; interface Props extends PropsWithChildren { - params: Promise<{ locale: LocaleType }>; + params: Promise<{ locale: string }>; } export default async function DefaultLayout({ params, children }: Props) { diff --git a/core/app/[locale]/(default)/page.tsx b/core/app/[locale]/(default)/page.tsx index 9f6855a51a..3147c224d7 100644 --- a/core/app/[locale]/(default)/page.tsx +++ b/core/app/[locale]/(default)/page.tsx @@ -8,7 +8,6 @@ import { revalidate } from '~/client/revalidate-target'; import { ProductCardCarousel } from '~/components/product-card-carousel'; import { ProductCardCarouselFragment } from '~/components/product-card-carousel/fragment'; import { Slideshow } from '~/components/slideshow'; -import { LocaleType } from '~/i18n/routing'; const HomePageQuery = graphql( ` @@ -35,7 +34,7 @@ const HomePageQuery = graphql( ); interface Props { - params: Promise<{ locale: LocaleType }>; + params: Promise<{ locale: string }>; } export default async function Home({ params }: Props) { diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 59d065a282..6fdd3353a1 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -5,7 +5,6 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Suspense } from 'react'; import { Breadcrumbs } from '~/components/breadcrumbs'; -import { LocaleType } from '~/i18n/routing'; import { Description } from './_components/description'; import { Details } from './_components/details'; @@ -17,7 +16,7 @@ import { Warranty } from './_components/warranty'; import { getProduct } from './page-data'; interface Props { - params: Promise<{ slug: string; locale: LocaleType }>; + params: Promise<{ slug: string; locale: string }>; searchParams: Promise>; } diff --git a/core/app/[locale]/(default)/product/[slug]/static/page.tsx b/core/app/[locale]/(default)/product/[slug]/static/page.tsx index 265e5f930e..2cb5384e7c 100644 --- a/core/app/[locale]/(default)/product/[slug]/static/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/static/page.tsx @@ -7,7 +7,7 @@ import { getChannelIdFromLocale } from '~/channels.config'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate as revalidateTarget } from '~/client/revalidate-target'; -import { locales, LocaleType } from '~/i18n/routing'; +import { locales } from '~/i18n/routing'; import ProductPage from '../page'; import { getProduct } from '../page-data'; @@ -60,7 +60,7 @@ export async function generateStaticParams() { } interface Props { - params: Promise<{ slug: string; locale: LocaleType }>; + params: Promise<{ slug: string; locale: string }>; } export async function generateMetadata(props: Props): Promise { diff --git a/core/app/[locale]/maintenance/page.tsx b/core/app/[locale]/maintenance/page.tsx index e7bef2881e..7bbd4b5810 100644 --- a/core/app/[locale]/maintenance/page.tsx +++ b/core/app/[locale]/maintenance/page.tsx @@ -6,7 +6,6 @@ import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { StoreLogo } from '~/components/store-logo'; import { StoreLogoFragment } from '~/components/store-logo/fragment'; -import { LocaleType } from '~/i18n/routing'; const MaintenancePageQuery = graphql( ` @@ -38,7 +37,7 @@ const Container = ({ children }: { children: ReactNode }) => ( ); interface Props { - params: Promise<{ locale: LocaleType }>; + params: Promise<{ locale: string }>; } export default async function Maintenance({ params }: Props) { diff --git a/core/app/[locale]/store-selector/_components/locale-link.tsx b/core/app/[locale]/store-selector/_components/locale-link.tsx index af94509353..28cc9e810e 100644 --- a/core/app/[locale]/store-selector/_components/locale-link.tsx +++ b/core/app/[locale]/store-selector/_components/locale-link.tsx @@ -1,5 +1,5 @@ import { Link } from '~/components/link'; -import { localeLanguageRegionMap, LocaleType } from '~/i18n/routing'; +import { localeLanguageRegionMap } from '~/i18n/routing'; import { cn } from '~/lib/utils'; export const LocaleLink = ({ locale, selected }: { locale: string; selected: boolean }) => { @@ -16,8 +16,7 @@ export const LocaleLink = ({ locale, selected }: { locale: string; selected: boo selected && 'border-black', )} href="/" - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - locale={locale as LocaleType} + locale={locale} >
{selectedLocale.flag}
diff --git a/core/app/[locale]/store-selector/page.tsx b/core/app/[locale]/store-selector/page.tsx index b4fed52841..89eb945f9b 100644 --- a/core/app/[locale]/store-selector/page.tsx +++ b/core/app/[locale]/store-selector/page.tsx @@ -5,7 +5,7 @@ import { graphql } from '~/client/graphql'; import { Link } from '~/components/link'; import { StoreLogo } from '~/components/store-logo'; import { StoreLogoFragment } from '~/components/store-logo/fragment'; -import { locales, LocaleType } from '~/i18n/routing'; +import { locales } from '~/i18n/routing'; import { LocaleLink } from './_components/locale-link'; @@ -31,7 +31,7 @@ export async function generateMetadata() { } interface Props { - params: Promise<{ locale: LocaleType }>; + params: Promise<{ locale: string }>; } export default async function StoreSelector({ params }: Props) { @@ -61,7 +61,6 @@ export default async function StoreSelector({ params }: Props) {
{locales.map((locale) => ( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition ))}
diff --git a/core/build-config/reader.ts b/core/build-config/reader.ts new file mode 100644 index 0000000000..d52642fc5f --- /dev/null +++ b/core/build-config/reader.ts @@ -0,0 +1,16 @@ +import rawBuildConfig from './build-config.json'; +import { buildConfigSchema, BuildConfigSchema } from './schema'; + +class BuildConfig { + private config = buildConfigSchema.parse(rawBuildConfig); + + get(key: K): BuildConfigSchema[K] { + if (key in this.config) { + return this.config[key]; + } + + throw new Error(`Key "${key}" not found in BuildConfig`); + } +} + +export const buildConfig = new BuildConfig(); diff --git a/core/build-config/schema.ts b/core/build-config/schema.ts new file mode 100644 index 0000000000..89aac2fab1 --- /dev/null +++ b/core/build-config/schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const buildConfigSchema = z.object({ + locales: z.array( + z.object({ + code: z.string(), + isDefault: z.boolean(), + }), + ), +}); + +export type BuildConfigSchema = z.infer; diff --git a/core/build-config/writer.ts b/core/build-config/writer.ts new file mode 100644 index 0000000000..ca413e159a --- /dev/null +++ b/core/build-config/writer.ts @@ -0,0 +1,27 @@ +/* eslint-disable no-console */ +import { writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { z } from 'zod'; + +import { buildConfigSchema } from './schema'; + +const destinationPath = dirname(fileURLToPath(import.meta.url)); +const CONFIG_FILE = join(destinationPath, 'build-config.json'); + +// This fn is only intended to be used in the build process (next.config.ts) +export async function writeBuildConfig(data: unknown) { + try { + buildConfigSchema.parse(data); + + await writeFile(CONFIG_FILE, JSON.stringify(data), 'utf8'); + } catch (error) { + if (error instanceof z.ZodError) { + console.error('Data validation failed:', error.errors); + } else { + console.error('Error writing build-config.json:', error); + } + + throw error; + } +} diff --git a/core/channels.config.ts b/core/channels.config.ts index a990b0396c..88b4563430 100644 --- a/core/channels.config.ts +++ b/core/channels.config.ts @@ -1,17 +1,10 @@ -import { type LocaleType } from './i18n/routing'; - -export type RecordFromLocales = { - [K in LocaleType]: string; -}; - // Set overrides per locale -const localeToChannelsMappings: Partial = { +const localeToChannelsMappings: Record = { // es: '12345', }; -function getChannelIdFromLocale(locale?: string) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return localeToChannelsMappings[locale as LocaleType] ?? process.env.BIGCOMMERCE_CHANNEL_ID; +function getChannelIdFromLocale(locale = '') { + return localeToChannelsMappings[locale] ?? process.env.BIGCOMMERCE_CHANNEL_ID; } export { getChannelIdFromLocale }; diff --git a/core/client/index.ts b/core/client/index.ts index c055902735..667184d8bc 100644 --- a/core/client/index.ts +++ b/core/client/index.ts @@ -2,8 +2,7 @@ import { createClient } from '@bigcommerce/catalyst-client'; import { headers } from 'next/headers'; import { getLocale } from 'next-intl/server'; -import { getChannelIdFromLocale } from '~/channels.config'; - +import { getChannelIdFromLocale } from '../channels.config'; import { backendUserAgent } from '../userAgent'; export const client = createClient({ diff --git a/core/components/ui/header/locale-switcher.tsx b/core/components/ui/header/locale-switcher.tsx index ccffde9718..b0e747f0f3 100644 --- a/core/components/ui/header/locale-switcher.tsx +++ b/core/components/ui/header/locale-switcher.tsx @@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl'; import { useMemo, useState } from 'react'; import { Link } from '~/components/link'; -import { LocaleType } from '~/i18n/routing'; import { Button } from '../button'; import { Select } from '../form'; @@ -19,7 +18,7 @@ type LanguagesByRegionMap = Record< >; interface Locale { - id: LocaleType; + id: string; region: string; language: string; flag: string; diff --git a/core/i18n/request.ts b/core/i18n/request.ts index a26bd693a0..cd484f46ae 100644 --- a/core/i18n/request.ts +++ b/core/i18n/request.ts @@ -1,13 +1,12 @@ import { notFound } from 'next/navigation'; import { getRequestConfig } from 'next-intl/server'; -import { locales, LocaleType } from './routing'; +import { locales } from './routing'; export default getRequestConfig(async ({ requestLocale }) => { const locale = await requestLocale; - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - if (!locales.includes(locale as LocaleType)) { + if (!locale || !locales.includes(locale)) { notFound(); } diff --git a/core/i18n/routing.ts b/core/i18n/routing.ts index d2e1d93bfe..fc72e02bac 100644 --- a/core/i18n/routing.ts +++ b/core/i18n/routing.ts @@ -1,35 +1,15 @@ import { createNavigation } from 'next-intl/navigation'; import { defineRouting } from 'next-intl/routing'; -// Enable locales by including them here. -// List includes locales with existing messages support. -export const locales = [ - 'en', - // 'da', - // 'es-419', - // 'es-AR', - // 'es-CL', - // 'es-CO', - // 'es-LA', - // 'es-MX', - // 'es-PE', - // 'es', - // 'it', - // 'nl', - // 'pl', - // 'pt', - // 'de', - // 'fr', - // 'ja', - // 'no', - // 'pt-BR', - // 'sv', -] as const; +import { buildConfig } from '~/build-config/reader'; -export type LocaleType = (typeof locales)[number]; +const localeNodes = buildConfig.get('locales'); + +export const locales = localeNodes.map((locale) => locale.code); +export const defaultLocale = localeNodes.find((locale) => locale.isDefault)?.code ?? 'en'; interface LocaleEntry { - id: LocaleType; + id: string; language: string; region: string; flag: string; @@ -41,26 +21,26 @@ interface LocaleEntry { */ export const localeLanguageRegionMap: LocaleEntry[] = [ { id: 'en', language: 'English', region: 'United States', flag: '🇺🇸' }, - // { id: 'da', language: 'Dansk', region: 'Danmark', flag: '🇩🇰' }, - // { id: 'es-419', language: 'Español', region: 'America Latina', flag: '' }, - // { id: 'es-AR', language: 'Español', region: 'Argentina', flag: '🇦🇷' }, - // { id: 'es-CL', language: 'Español', region: 'Chile', flag: '🇨🇱' }, - // { id: 'es-CO', language: 'Español', region: 'Colombia', flag: '🇨🇴' }, - // { id: 'es-LA', language: 'Español', region: 'America Latina', flag: '' }, - // { id: 'es-MX', language: 'Español', region: 'México', flag: '🇲🇽' }, - // { id: 'es-PE', language: 'Español', region: 'Perú', flag: '🇵🇪' }, - // { id: 'es', language: 'Español', region: 'España', flag: '🇪🇸' }, - // { id: 'it', language: 'Italiano', region: 'Italia', flag: '🇮🇹' }, - // { id: 'nl', language: 'Nederlands', region: 'Nederland', flag: '🇳🇱' }, - // { id: 'pl', language: 'Polski', region: 'Polska', flag: '🇵🇱' }, - // { id: 'pt', language: 'Português', region: 'Portugal', flag: '🇵🇹' }, - // { id: 'de', language: 'Deutsch', region: 'Deutschland', flag: '🇩🇪' }, - // { id: 'fr', language: 'Français', region: 'France', flag: '🇫🇷' }, - // { id: 'ja', language: '日本語', region: '日本', flag: '🇯🇵' }, - // { id: 'no', language: 'Norsk', region: 'Norge', flag: '🇳🇴' }, - // { id: 'pt-BR', language: 'Português', region: 'Brasil', flag: '🇧🇷' }, - // { id: 'sv', language: 'Svenska', region: 'Sverige', flag: '🇸🇪' }, -]; + { id: 'da', language: 'Dansk', region: 'Danmark', flag: '🇩🇰' }, + { id: 'es-419', language: 'Español', region: 'America Latina', flag: '' }, + { id: 'es-AR', language: 'Español', region: 'Argentina', flag: '🇦🇷' }, + { id: 'es-CL', language: 'Español', region: 'Chile', flag: '🇨🇱' }, + { id: 'es-CO', language: 'Español', region: 'Colombia', flag: '🇨🇴' }, + { id: 'es-LA', language: 'Español', region: 'America Latina', flag: '' }, + { id: 'es-MX', language: 'Español', region: 'México', flag: '🇲🇽' }, + { id: 'es-PE', language: 'Español', region: 'Perú', flag: '🇵🇪' }, + { id: 'es', language: 'Español', region: 'España', flag: '🇪🇸' }, + { id: 'it', language: 'Italiano', region: 'Italia', flag: '🇮🇹' }, + { id: 'nl', language: 'Nederlands', region: 'Nederland', flag: '🇳🇱' }, + { id: 'pl', language: 'Polski', region: 'Polska', flag: '🇵🇱' }, + { id: 'pt', language: 'Português', region: 'Portugal', flag: '🇵🇹' }, + { id: 'de', language: 'Deutsch', region: 'Deutschland', flag: '🇩🇪' }, + { id: 'fr', language: 'Français', region: 'France', flag: '🇫🇷' }, + { id: 'ja', language: '日本語', region: '日本', flag: '🇯🇵' }, + { id: 'no', language: 'Norsk', region: 'Norge', flag: '🇳🇴' }, + { id: 'pt-BR', language: 'Português', region: 'Brasil', flag: '🇧🇷' }, + { id: 'sv', language: 'Svenska', region: 'Sverige', flag: '🇸🇪' }, +].filter(({ id }) => locales.includes(id)); enum LocalePrefixes { ALWAYS = 'always', @@ -72,8 +52,6 @@ enum LocalePrefixes { export const localePrefix = LocalePrefixes.ASNEEDED; -export const defaultLocale = 'en'; - export const routing = defineRouting({ locales, defaultLocale, diff --git a/core/next.config.ts b/core/next.config.ts index 0969ff1b69..5a6fe39ef3 100644 --- a/core/next.config.ts +++ b/core/next.config.ts @@ -3,11 +3,27 @@ import type { NextConfig } from 'next'; import createNextIntlPlugin from 'next-intl/plugin'; import { optimize } from 'webpack'; +import { writeBuildConfig } from './build-config/writer'; +import { client } from './client'; +import { graphql } from './client/graphql'; import { cspHeader } from './lib/content-security-policy'; const withNextIntl = createNextIntlPlugin(); -export default (): NextConfig => { +const LocaleQuery = graphql(` + query LocaleQuery { + site { + settings { + locales { + code + isDefault + } + } + } + } +`); + +export default async (): Promise => { let nextConfig: NextConfig = { reactStrictMode: true, experimental: { @@ -69,5 +85,13 @@ export default (): NextConfig => { nextConfig = withBundleAnalyzer(nextConfig); } + await writeLocaleToBuildConfig(); + return nextConfig; }; + +async function writeLocaleToBuildConfig() { + const { data } = await client.fetch({ document: LocaleQuery }); + + await writeBuildConfig({ locales: data.site.settings?.locales }); +}