Skip to content
Merged
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
1 change: 0 additions & 1 deletion .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
"config ⚙️":
- i18n.config.json
- next.config.js
- next-sitemap.config.js
- tsconfig.json
- .nvmrc
- .eslintignore
Expand Down
15 changes: 9 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ pnpm events-import # Import community events

## SEO & Meta

- Sitemap generation with `next-sitemap`
- Sitemap generation in `app/sitemap.ts`
- Meta tags and Open Graph optimization
- Structured data for search engines
- Security headers (X-Frame-Options: DENY)
Expand Down Expand Up @@ -210,26 +210,28 @@ The site uses a GDPR-compliant, cookie-less A/B testing system integrated with M
### Adding a New A/B Test

1. **Create experiment in Matomo dashboard**:

- Go to Experiments → Manage Experiments
- Create new experiment with desired name (e.g., "HomepageHero")
- Add variations with weights (original is implicit)
- Set status to "running"

2. **Implement in component**:

```tsx
import ABTestWrapper from "@/components/AB/TestWrapper"

<ABTestWrapper
testKey="HomepageHero" // Must match Matomo experiment name exactly
;<ABTestWrapper
testKey="HomepageHero" // Must match Matomo experiment name exactly
variants={[
<OriginalComponent key="current-hero" />, // Index 0: Original
<NewComponent key="redesigned-hero" /> // Index 1: Variation
<OriginalComponent key="current-hero" />, // Index 0: Original
<NewComponent key="redesigned-hero" />, // Index 1: Variation
]}
fallback={<OriginalComponent />}
/>
```

**Important**:

- Variants matched by **array index**, not names
- Array order must match Matomo experiment order exactly
- JSX `key` props become debug panel labels: `"redesigned-hero"` → `"Redesigned Hero"`
Expand All @@ -244,6 +246,7 @@ The site uses a GDPR-compliant, cookie-less A/B testing system integrated with M
### Environment Variables

Required for Matomo integration:

- `NEXT_PUBLIC_MATOMO_URL` - Matomo instance URL
- `NEXT_PUBLIC_MATOMO_SITE_ID` - Site ID in Matomo
- `MATOMO_API_TOKEN` - API token with experiments access
Expand Down
41 changes: 41 additions & 0 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { MetadataRoute } from "next"

import { getFullUrl } from "@/lib/utils/url"

import { DEFAULT_LOCALE } from "@/lib/constants"

import { getAllPagesWithTranslations } from "@/lib/i18n/translationRegistry"

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const pages = await getAllPagesWithTranslations()

const entries: MetadataRoute.Sitemap = []

for (const { slug, translatedLocales } of pages) {
const normalizedSlug = slug.startsWith("/") ? slug : `/${slug}`

for (const locale of translatedLocales) {
const url = getFullUrl(locale, normalizedSlug)

// Drop the `/en` root entry to avoid duplicating `/`
// This happens when slug is "/" and locale is default
if (
locale === DEFAULT_LOCALE &&
(normalizedSlug === "/" || normalizedSlug === "")
) {
continue
}

const isDefaultLocale = locale === DEFAULT_LOCALE

entries.push({
url,
changeFrequency: isDefaultLocale ? "weekly" : "monthly",
priority: isDefaultLocale ? 0.7 : 0.5,
lastModified: new Date(),
})
}
}

return entries
}
31 changes: 0 additions & 31 deletions next-sitemap.config.js

This file was deleted.

2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "next-sitemap",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
Expand Down Expand Up @@ -82,7 +81,6 @@
"next": "^14.2.32",
"next-intl": "^3.26.3",
"next-mdx-remote": "^5.0.0",
"next-sitemap": "^4.2.3",
"next-themes": "^0.3.0",
"prism-react-renderer": "1.1.0",
"prismjs": "^1.30.0",
Expand Down
28 changes: 0 additions & 28 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 0 additions & 30 deletions src/lib/i18n/pageTranslation.ts

This file was deleted.

114 changes: 114 additions & 0 deletions src/lib/i18n/translationRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { existsSync } from "fs"
import { join } from "path"

import {
DEFAULT_LOCALE,
LOCALES_CODES,
TRANSLATIONS_DIR,
} from "@/lib/constants"

import { getPostSlugs } from "../utils/md"
import { getStaticPagePaths } from "../utils/staticPages"
import { getPrimaryNamespaceForPath } from "../utils/translations"
import { addSlashes } from "../utils/url"

import { areNamespacesTranslated } from "./translationStatus"

async function isMdPageTranslated(
locale: string,
slug: string
): Promise<boolean> {
if (locale === DEFAULT_LOCALE) {
return true
}

const translationPath = join(TRANSLATIONS_DIR, locale, slug, "index.md")
return existsSync(translationPath)
}

async function isIntlPageTranslated(
locale: string,
path: string
): Promise<boolean> {
const primaryNamespace = getPrimaryNamespaceForPath(path)

if (!primaryNamespace) {
return locale === DEFAULT_LOCALE
}

return areNamespacesTranslated(locale, [primaryNamespace])
}

function getPageType(slug: string): "md" | "intl" {
const normalizedSlug = addSlashes(slug)
const primaryNamespace = getPrimaryNamespaceForPath(normalizedSlug)
return primaryNamespace ? "intl" : "md"
}

/**
* Get all translated locales for a given page slug.
* Works for both MD pages and intl pages.
*
* @param slug - Page slug/path (e.g., "about" for MD or "/wallets/" for intl)
* @returns Promise resolving to array of locale codes that have translations
* @example
* await getTranslatedLocales("about") // => ["en", "es", "fr"]
* await getTranslatedLocales("/wallets/") // => ["en", "es"]
*/
export async function getTranslatedLocales(slug: string): Promise<string[]> {
const pageType = getPageType(slug)
const translatedLocales: string[] = []

for (const locale of LOCALES_CODES) {
let isTranslated: boolean

if (pageType === "md") {
const mdSlug = slug.replace(/^\/+|\/+$/g, "")
isTranslated = await isMdPageTranslated(locale, mdSlug)
} else {
const normalizedPath = addSlashes(slug)
isTranslated = await isIntlPageTranslated(locale, normalizedPath)
}

if (isTranslated) {
translatedLocales.push(locale)
}
}

return translatedLocales
}

type PageWithTranslations = {
slug: string
translatedLocales: string[]
type: "md" | "intl"
}

export async function getAllPagesWithTranslations(): Promise<
PageWithTranslations[]
> {
const pages: PageWithTranslations[] = []

const mdSlugs = await getPostSlugs("/")
const intlPaths = getStaticPagePaths()

for (const slug of mdSlugs) {
const translatedLocales = await getTranslatedLocales(slug)
pages.push({
slug,
translatedLocales,
type: "md",
})
}

for (const path of intlPaths) {
const translatedLocales = await getTranslatedLocales(path)
pages.push({
slug: path,
translatedLocales,
type: "intl",
})
}

return pages
}
10 changes: 6 additions & 4 deletions src/lib/i18n/translationStatus.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DEFAULT_LOCALE } from "@/lib/constants"
import { existsSync } from "fs"
import { join } from "path"

import { loadMessages } from "@/lib/i18n/loadMessages"
import { DEFAULT_LOCALE } from "@/lib/constants"

/**
* Determine whether all required i18n namespaces exist for a given locale.
Expand All @@ -12,8 +13,9 @@ export async function areNamespacesTranslated(
): Promise<boolean> {
if (locale === DEFAULT_LOCALE) return true

const localeMessages = await loadMessages(locale)
const intlPath = join(process.cwd(), "src/intl")

return namespaces.every((ns) =>
Object.prototype.hasOwnProperty.call(localeMessages, ns)
existsSync(join(intlPath, locale, `${ns}.json`))
)
}
Loading