create-brainerce-store 1.66.0 → 1.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/index.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/nextjs/base/.env.local.ejs +52 -24
  4. package/templates/nextjs/base/.eslintrc.json +51 -57
  5. package/templates/nextjs/base/AGENTS.md.ejs +114 -81
  6. package/templates/nextjs/base/CLAUDE.md.ejs +125 -92
  7. package/templates/nextjs/base/next.config.ts +103 -86
  8. package/templates/nextjs/base/scripts/fetch-store-info.mjs +104 -97
  9. package/templates/nextjs/base/src/app/agents.md/route.ts +4 -3
  10. package/templates/nextjs/base/src/app/api/auth/me/route.ts +65 -59
  11. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +255 -242
  12. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +317 -308
  13. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +277 -276
  14. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +4 -3
  15. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +22 -12
  16. package/templates/nextjs/base/src/app/error.tsx.ejs +53 -0
  17. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +46 -46
  18. package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +25 -26
  19. package/templates/nextjs/base/src/app/layout.tsx.ejs +273 -257
  20. package/templates/nextjs/base/src/app/llms.txt/route.ts +4 -3
  21. package/templates/nextjs/base/src/app/opengraph-image.tsx +1 -1
  22. package/templates/nextjs/base/src/app/page.tsx +65 -64
  23. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +98 -92
  24. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +22 -11
  25. package/templates/nextjs/base/src/app/robots.ts +3 -2
  26. package/templates/nextjs/base/src/app/sitemap.ts +4 -3
  27. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +294 -292
  28. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +60 -59
  29. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +60 -61
  30. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +2 -1
  31. package/templates/nextjs/base/src/core/lib/brainerce.server.ts +81 -0
  32. package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +60 -110
  33. package/templates/nextjs/base/src/core/lib/site-url.ts +230 -0
  34. package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
  35. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
  36. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +301 -285
  37. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +97 -96
  38. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +142 -141
  39. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +139 -138
  40. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +295 -267
  41. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
  42. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +72 -71
  43. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +83 -82
  44. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +120 -119
  45. package/templates/nextjs/ui-canvas/product/faq-section.tsx.ejs +54 -0
  46. package/templates/nextjs/ui-canvas/product/review-form.tsx +267 -266
  47. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
@@ -1,64 +1,65 @@
1
- import type { Metadata } from 'next';
2
- import { getServerClient } from '@/core/lib/brainerce';
3
- import { buildMetaDescription } from '@/core/lib/seo';
4
- import { OrganizationJsonLd } from '@/components/seo/organization-json-ld';
5
- import { RichTextBlock } from '@/ui/layout/rich-text-block';
6
- import { HomeClient } from '@/ui/home/home-client';
7
-
8
- // Build homepage metadata server-side so Google, Facebook, WhatsApp etc.
9
- // see a real <meta name="description"> on first request. Falls back to the
10
- // store name when the merchant hasn't authored a meta description yet.
11
- export async function generateMetadata(): Promise<Metadata> {
12
- try {
13
- const storeInfo = await getServerClient().getStoreInfo();
14
- const description = buildMetaDescription(storeInfo.metaDescription) || undefined;
15
- return {
16
- title: storeInfo.name,
17
- description,
18
- openGraph: {
19
- title: storeInfo.name,
20
- description,
21
- type: 'website',
22
- },
23
- twitter: {
24
- card: 'summary_large_image',
25
- title: storeInfo.name,
26
- description,
27
- },
28
- };
29
- } catch {
30
- return {};
31
- }
32
- }
33
-
34
- export default async function HomePage() {
35
- // Fetch store info server-side so the JSON-LD is in the initial HTML —
36
- // crawlers and AI Overviews see it without needing to execute JavaScript.
37
- // Failure here is non-fatal: the client component re-fetches via the
38
- // StoreProvider so the page still renders.
39
- //
40
- // Also fetch the merchant's homepage intro (RICH_TEXT key='main'). When
41
- // present, it renders as a sanitized HTML block above the product grid —
42
- // typical use is brand introduction, value-prop copy, or a seasonal
43
- // announcement that needs more formatting than the AnnouncementBar.
44
- // When absent, RichTextBlock returns null cleanly so the layout collapses
45
- // to just the product grid.
46
- const client = getServerClient();
47
- const [storeInfo, intro] = await Promise.all([
48
- client.getStoreInfo().catch(() => null),
49
- client.content.richText.get('main').catch(() => null),
50
- ]);
51
-
52
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
53
-
54
- return (
55
- <>
56
- {storeInfo && baseUrl ? <OrganizationJsonLd storeInfo={storeInfo} baseUrl={baseUrl} /> : null}
57
- <RichTextBlock
58
- block={intro}
59
- className="prose prose-sm dark:prose-invert mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8"
60
- />
61
- <HomeClient />
62
- </>
63
- );
64
- }
1
+ import type { Metadata } from 'next';
2
+ import { getServerClient } from '@/core/lib/brainerce.server';
3
+ import { buildMetaDescription } from '@/core/lib/seo';
4
+ import { OrganizationJsonLd } from '@/components/seo/organization-json-ld';
5
+ import { getCanonicalSiteUrl } from '@/core/lib/site-url';
6
+ import { RichTextBlock } from '@/ui/layout/rich-text-block';
7
+ import { HomeClient } from '@/ui/home/home-client';
8
+
9
+ // Build homepage metadata server-side so Google, Facebook, WhatsApp etc.
10
+ // see a real <meta name="description"> on first request. Falls back to the
11
+ // store name when the merchant hasn't authored a meta description yet.
12
+ export async function generateMetadata(): Promise<Metadata> {
13
+ try {
14
+ const storeInfo = await (await getServerClient()).getStoreInfo();
15
+ const description = buildMetaDescription(storeInfo.metaDescription) || undefined;
16
+ return {
17
+ title: storeInfo.name,
18
+ description,
19
+ openGraph: {
20
+ title: storeInfo.name,
21
+ description,
22
+ type: 'website',
23
+ },
24
+ twitter: {
25
+ card: 'summary_large_image',
26
+ title: storeInfo.name,
27
+ description,
28
+ },
29
+ };
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ export default async function HomePage() {
36
+ // Fetch store info server-side so the JSON-LD is in the initial HTML —
37
+ // crawlers and AI Overviews see it without needing to execute JavaScript.
38
+ // Failure here is non-fatal: the client component re-fetches via the
39
+ // StoreProvider so the page still renders.
40
+ //
41
+ // Also fetch the merchant's homepage intro (RICH_TEXT key='main'). When
42
+ // present, it renders as a sanitized HTML block above the product grid —
43
+ // typical use is brand introduction, value-prop copy, or a seasonal
44
+ // announcement that needs more formatting than the AnnouncementBar.
45
+ // When absent, RichTextBlock returns null cleanly so the layout collapses
46
+ // to just the product grid.
47
+ const client = await getServerClient();
48
+ const [storeInfo, intro] = await Promise.all([
49
+ client.getStoreInfo().catch(() => null),
50
+ client.content.richText.get('main').catch(() => null),
51
+ ]);
52
+
53
+ const baseUrl = await getCanonicalSiteUrl();
54
+
55
+ return (
56
+ <>
57
+ {storeInfo && baseUrl ? <OrganizationJsonLd storeInfo={storeInfo} baseUrl={baseUrl} /> : null}
58
+ <RichTextBlock
59
+ block={intro}
60
+ className="prose prose-sm dark:prose-invert mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8"
61
+ />
62
+ <HomeClient />
63
+ </>
64
+ );
65
+ }
@@ -1,92 +1,98 @@
1
- /**
2
- * Static page route — fully driven by the merchant's dashboard.
3
- *
4
- * The merchant creates pages in the Brainerce dashboard under
5
- * Sell → Content → Page (one row per page, each with its own `slug`)
6
- *
7
- * This catch-all renders any PAGE by its `data.slug`. `/pages/about` →
8
- * fetches PAGE with slug='about'. Mounting under `/pages/` (rather than
9
- * the root) keeps the route from clashing with existing static routes
10
- * like /cart, /login, /products.
11
- *
12
- * Security: page HTML is sanitized via `sanitizeHtml` before injecting.
13
- */
14
- import * as React from 'react';
15
- import type { Metadata } from 'next';
16
- import { notFound } from 'next/navigation';
17
-
18
- import { getServerClient } from '@/core/lib/brainerce';
19
- import { sanitizeHtml } from '@/core/lib/sanitize';
20
- import { decodeSlug } from '@/core/lib/utils';
21
-
22
- <% if (i18nEnabled) { %>
23
- type PageProps = {
24
- params: Promise<{ locale: string; slug: string }>;
25
- };
26
-
27
- export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
28
- const { locale, slug: rawSlug } = await params;
29
- const slug = decodeSlug(rawSlug);
30
- const page = await getServerClient(locale).content.page.getBySlug(slug, locale).catch(() => null);
31
- if (!page) return { title: slug };
32
- const seo = page.data.seo ?? {};
33
- return {
34
- title: seo.title ?? page.data.title,
35
- description: seo.description,
36
- openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
37
- };
38
- }
39
-
40
- export default async function StaticPage({ params }: PageProps) {
41
- const { locale, slug: rawSlug } = await params;
42
- const slug = decodeSlug(rawSlug);
43
- const page = await getServerClient(locale).content.page.getBySlug(slug, locale).catch(() => null);
44
- if (!page) notFound();
45
- return (
46
- <article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
47
- <h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">
48
- {page.data.title}
49
- </h1>
50
- <div
51
- className="prose prose-sm dark:prose-invert max-w-none"
52
- dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.data.html) }}
53
- />
54
- </article>
55
- );
56
- }
57
- <% } else { %>
58
- type PageProps = {
59
- params: Promise<{ slug: string }>;
60
- };
61
-
62
- export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
63
- const { slug: rawSlug } = await params;
64
- const slug = decodeSlug(rawSlug);
65
- const page = await getServerClient().content.page.getBySlug(slug).catch(() => null);
66
- if (!page) return { title: slug };
67
- const seo = page.data.seo ?? {};
68
- return {
69
- title: seo.title ?? page.data.title,
70
- description: seo.description,
71
- openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
72
- };
73
- }
74
-
75
- export default async function StaticPage({ params }: PageProps) {
76
- const { slug: rawSlug } = await params;
77
- const slug = decodeSlug(rawSlug);
78
- const page = await getServerClient().content.page.getBySlug(slug).catch(() => null);
79
- if (!page) notFound();
80
- return (
81
- <article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
82
- <h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">
83
- {page.data.title}
84
- </h1>
85
- <div
86
- className="prose prose-sm dark:prose-invert max-w-none"
87
- dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.data.html) }}
88
- />
89
- </article>
90
- );
91
- }
92
- <% } %>
1
+ /**
2
+ * Static page route — fully driven by the merchant's dashboard.
3
+ *
4
+ * The merchant creates pages in the Brainerce dashboard under
5
+ * Sell → Content → Page (one row per page, each with its own `slug`)
6
+ *
7
+ * This catch-all renders any PAGE by its `data.slug`. `/pages/about` →
8
+ * fetches PAGE with slug='about'. Mounting under `/pages/` (rather than
9
+ * the root) keeps the route from clashing with existing static routes
10
+ * like /cart, /login, /products.
11
+ *
12
+ * Security: page HTML is sanitized via `sanitizeHtml` before injecting.
13
+ */
14
+ import * as React from 'react';
15
+ import type { Metadata } from 'next';
16
+ import { notFound } from 'next/navigation';
17
+
18
+ import { getServerClient } from '@/core/lib/brainerce.server';
19
+ import { sanitizeHtml } from '@/core/lib/sanitize';
20
+ import { decodeSlug } from '@/core/lib/utils';
21
+
22
+ <% if (i18nEnabled) { %>
23
+ type PageProps = {
24
+ params: Promise<{ locale: string; slug: string }>;
25
+ };
26
+
27
+ export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
28
+ const { locale, slug: rawSlug } = await params;
29
+ const slug = decodeSlug(rawSlug);
30
+ const page = await (await getServerClient(locale)).content.page.getBySlug(slug, locale).catch(() => null);
31
+ if (!page) return { title: slug };
32
+ const seo = page.data.seo ?? {};
33
+ return {
34
+ title: seo.title ?? page.data.title,
35
+ description: seo.description,
36
+ openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
37
+ };
38
+ }
39
+
40
+ export default async function StaticPage({ params }: PageProps) {
41
+ const { locale, slug: rawSlug } = await params;
42
+ const slug = decodeSlug(rawSlug);
43
+ // getBySlug returns null only on a genuine 404; any other API failure (a
44
+ // 403 from the channel's origin check, a network error) throws and surfaces
45
+ // as the error it is instead of masquerading as a missing page.
46
+ const page = await (await getServerClient(locale)).content.page.getBySlug(slug, locale);
47
+ if (!page) notFound();
48
+ return (
49
+ <article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
50
+ <h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">
51
+ {page.data.title}
52
+ </h1>
53
+ <div
54
+ className="prose prose-sm dark:prose-invert max-w-none"
55
+ dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.data.html) }}
56
+ />
57
+ </article>
58
+ );
59
+ }
60
+ <% } else { %>
61
+ type PageProps = {
62
+ params: Promise<{ slug: string }>;
63
+ };
64
+
65
+ export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
66
+ const { slug: rawSlug } = await params;
67
+ const slug = decodeSlug(rawSlug);
68
+ const page = await (await getServerClient()).content.page.getBySlug(slug).catch(() => null);
69
+ if (!page) return { title: slug };
70
+ const seo = page.data.seo ?? {};
71
+ return {
72
+ title: seo.title ?? page.data.title,
73
+ description: seo.description,
74
+ openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
75
+ };
76
+ }
77
+
78
+ export default async function StaticPage({ params }: PageProps) {
79
+ const { slug: rawSlug } = await params;
80
+ const slug = decodeSlug(rawSlug);
81
+ // getBySlug returns null only on a genuine 404; any other API failure (a
82
+ // 403 from the channel's origin check, a network error) throws and surfaces
83
+ // as the error it is instead of masquerading as a missing page.
84
+ const page = await (await getServerClient()).content.page.getBySlug(slug);
85
+ if (!page) notFound();
86
+ return (
87
+ <article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
88
+ <h1 className="text-foreground mb-6 text-2xl font-semibold sm:text-3xl">
89
+ {page.data.title}
90
+ </h1>
91
+ <div
92
+ className="prose prose-sm dark:prose-invert max-w-none"
93
+ dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.data.html) }}
94
+ />
95
+ </article>
96
+ );
97
+ }
98
+ <% } %>
@@ -1,11 +1,12 @@
1
1
  import type { Metadata } from 'next';
2
2
  import { notFound, permanentRedirect } from 'next/navigation';
3
- import { getProductPriceInfo } from 'brainerce';
4
- import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
3
+ import { BrainerceError, getProductPriceInfo } from 'brainerce';
4
+ import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
5
5
  import { resolveCurrency } from '@/core/lib/resolve-currency';
6
6
  import { buildMetaDescription } from '@/core/lib/seo';
7
7
  import { decodeSlug } from '@/core/lib/utils';
8
8
  import { ProductJsonLd } from '@/components/seo/product-json-ld';
9
+ import { getCanonicalSiteUrl } from '@/core/lib/site-url';
9
10
  import { Breadcrumbs } from '@/components/seo/breadcrumbs';
10
11
  import { ReviewsSection } from '@/ui/product/reviews-section';
11
12
  import { ProductFaqSection } from '@/ui/product/faq-section';
@@ -38,7 +39,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
38
39
  const slug = decodeSlug(rawSlug);
39
40
 
40
41
  try {
41
- const client = getServerClient(locale);
42
+ const client = await getServerClient(locale);
42
43
  // Fetch product + store info in parallel; storeInfo gives us the live
43
44
  // currency for OG price tags (with env-var + USD fallbacks so we never
44
45
  // silently emit a wrong currency when the API is briefly unreachable).
@@ -70,7 +71,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
70
71
  // Locales come from storeInfo.i18n — already fetched above, zero extra cost.
71
72
  const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
72
73
  const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
73
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
74
+ const baseUrl = await getCanonicalSiteUrl();
74
75
  const baseSlug = product.slug || slug;
75
76
  const localeSlugs = product.localeSlugs ?? {};
76
77
 
@@ -122,10 +123,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
122
123
  ...(brandName ? { 'product:brand': brandName } : {}),
123
124
  },
124
125
  };
125
- } catch {
126
- return {
127
- title: 'Product not found',
128
- };
126
+ } catch (err) {
127
+ if (err instanceof BrainerceError && err.statusCode === 404) {
128
+ return { title: 'Product not found' };
129
+ }
130
+ // Anything else (channel origin rejection, network failure) hits the page
131
+ // render below too and surfaces there — don't stamp a misleading title.
132
+ return {};
129
133
  }
130
134
  }
131
135
 
@@ -133,11 +137,18 @@ export default async function ProductDetailPage({ params }: Props) {
133
137
  const { slug: rawSlug, locale } = await params;
134
138
  const slug = decodeSlug(rawSlug);
135
139
 
136
- const client = getServerClient(locale);
140
+ const client = await getServerClient(locale);
137
141
  let product;
138
142
  try {
139
143
  product = await client.getProductBySlug(slug);
140
- } catch {
144
+ } catch (err) {
145
+ // Only a genuine 404 may become a storefront 404. Anything else — a 403
146
+ // from the channel's origin check, a network failure, a 5xx — is a
147
+ // configuration or availability problem; disguising it as "product not
148
+ // found" sends the merchant hunting for a slug bug that isn't there.
149
+ if (!(err instanceof BrainerceError && err.statusCode === 404)) {
150
+ throw err;
151
+ }
141
152
  // The slug may have been RENAMED — the platform records every rename.
142
153
  // 301 the old URL to the current slug so its ranking and inbound links
143
154
  // carry over; genuine unknowns fall through to the 404 page. Default
@@ -153,7 +164,7 @@ export default async function ProductDetailPage({ params }: Props) {
153
164
  notFound();
154
165
  }
155
166
 
156
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
167
+ const baseUrl = await getCanonicalSiteUrl();
157
168
  const productUrl = `${baseUrl}/products/${slug}`;
158
169
  // Reuse the cached storeInfo from generateMetadata's call — React cache()
159
170
  // collapses both into one backend request per render. resolveCurrency owns
@@ -1,4 +1,5 @@
1
1
  import type { MetadataRoute } from 'next';
2
+ import { getCanonicalSiteUrl } from '@/core/lib/site-url';
2
3
 
3
4
  /**
4
5
  * robots.txt with an explicit AI-crawler policy.
@@ -47,8 +48,8 @@ const AI_TRAINING_BOTS = [
47
48
  'Applebot-Extended',
48
49
  ];
49
50
 
50
- export default function robots(): MetadataRoute.Robots {
51
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
51
+ export default async function robots(): Promise<MetadataRoute.Robots> {
52
+ const baseUrl = await getCanonicalSiteUrl();
52
53
 
53
54
  return {
54
55
  rules: [
@@ -4,7 +4,8 @@ import {
4
4
  getCategorySitemapEntries,
5
5
  getProductSitemapEntries,
6
6
  } from 'brainerce';
7
- import { getServerClient } from '@/core/lib/brainerce';
7
+ import { getServerClient } from '@/core/lib/brainerce.server';
8
+ import { getCanonicalSiteUrl } from '@/core/lib/site-url';
8
9
 
9
10
  /**
10
11
  * Storefront sitemap: static pages + every published product, category,
@@ -16,9 +17,9 @@ import { getServerClient } from '@/core/lib/brainerce';
16
17
  * The helper uses a dedicated lightweight endpoint with a 5000-product cap.
17
18
  */
18
19
  export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
19
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
20
+ const baseUrl = await getCanonicalSiteUrl();
20
21
 
21
- const client = getServerClient();
22
+ const client = await getServerClient();
22
23
  const storeInfo = await client.getStoreInfo().catch(() => null);
23
24
  const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
24
25
  const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';