diff --git a/package.json b/package.json
index e9a286bca..0d701db8b 100644
--- a/package.json
+++ b/package.json
@@ -100,6 +100,7 @@
"pg": "^8.22.0",
"prisma": "^7.8.0",
"prop-types": "^15.8.1",
+ "punycode": "^2.3.1",
"react": "^19.2.7",
"react-aria-components": "^1.19.0",
"react-dom": "^19.2.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a52e43b2e..da88a5ce2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -149,6 +149,9 @@ importers:
prop-types:
specifier: ^15.8.1
version: 15.8.1
+ punycode:
+ specifier: ^2.3.1
+ version: 2.3.1
react:
specifier: ^19.2.7
version: 19.2.7
diff --git a/src/app/(main)/admin/websites/AdminWebsitesTable.tsx b/src/app/(main)/admin/websites/AdminWebsitesTable.tsx
index 12698e730..f33b4c35d 100644
--- a/src/app/(main)/admin/websites/AdminWebsitesTable.tsx
+++ b/src/app/(main)/admin/websites/AdminWebsitesTable.tsx
@@ -7,6 +7,7 @@ import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages } from '@/components/hooks';
import { Edit, Trash, Users } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
+import { decodePunycodeDomain } from '@/lib/format';
export function AdminWebsitesTable({ data = [], ...props }: { data: any[] }) {
const { t, labels } = useMessages();
@@ -23,7 +24,7 @@ export function AdminWebsitesTable({ data = [], ...props }: { data: any[] }) {
)}
}>
- {(row: any) => {row.domain}}
+ {(row: any) => {decodePunycodeDomain(row.domain)}}
{(row: any) => {
diff --git a/src/app/(main)/websites/WebsitesTable.tsx b/src/app/(main)/websites/WebsitesTable.tsx
index e6716f9e4..4b3e13ce3 100644
--- a/src/app/(main)/websites/WebsitesTable.tsx
+++ b/src/app/(main)/websites/WebsitesTable.tsx
@@ -5,6 +5,7 @@ import { LinkButton } from '@/components/common/LinkButton';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages, useNavigation, useWebsiteListChartsQuery } from '@/components/hooks';
import { SquarePen } from '@/components/icons';
+import { decodePunycodeDomain } from '@/lib/format';
import { WebsiteSparkline } from './WebsiteSparkline';
export interface WebsitesTableProps extends DataTableProps {
@@ -42,8 +43,12 @@ export function WebsitesTable({
style={{ minWidth: 0 }}
>
{(row: any) => (
-
- {row.domain}
+
+ {decodePunycodeDomain(row.domain)}
)}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx
index b96d3c6e7..16a81edff 100644
--- a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx
@@ -4,6 +4,7 @@ import Link from '@/components/common/Link';
import { PageHeader } from '@/components/common/PageHeader';
import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
import { ArrowLeft, Globe } from '@/components/icons';
+import { decodePunycodeDomain } from '@/lib/format';
export function WebsiteSettingsHeader() {
const website = useWebsite();
@@ -17,7 +18,11 @@ export function WebsiteSettingsHeader() {
} label={t(labels.website)} />
- } />
+ }
+ />
>
);
}
diff --git a/src/components/metrics/MetricLabel.tsx b/src/components/metrics/MetricLabel.tsx
index 21618b90d..c0b9857d5 100644
--- a/src/components/metrics/MetricLabel.tsx
+++ b/src/components/metrics/MetricLabel.tsx
@@ -10,6 +10,7 @@ import {
useRegionNames,
} from '@/components/hooks';
import { GROUPED_DOMAINS } from '@/lib/constants';
+import { decodePunycodeDomain } from '@/lib/format';
export interface MetricLabelProps {
type: string;
@@ -113,7 +114,7 @@ export function MetricLabel({ type, data }: MetricLabelProps) {
type="referrer"
value={label}
externalUrl={`https://${label}`}
- label={!label && t(labels.none)}
+ label={!label ? t(labels.none) : decodePunycodeDomain(label)}
icon={}
/>
);
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
index 4f9b192a2..b70b5f2e1 100644
--- a/src/lib/format.test.ts
+++ b/src/lib/format.test.ts
@@ -42,3 +42,9 @@ test('truncateString', () => {
expect(format.truncateString('hello', 3)).toBe('hel');
expect(format.truncateString(undefined, 3)).toBeUndefined();
});
+
+test('decodePunycodeDomain', () => {
+ expect(format.decodePunycodeDomain('xn--bcher-kva.example')).toBe('bücher.example');
+ expect(format.decodePunycodeDomain('example.com')).toBe('example.com');
+ expect(format.decodePunycodeDomain(undefined)).toBeUndefined();
+});
diff --git a/src/lib/format.ts b/src/lib/format.ts
index a92fb4423..07110d935 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -1,3 +1,4 @@
+import { toUnicode } from 'punycode';
import { DEFAULT_CURRENCY } from './constants';
export function parseTime(val: number) {
@@ -119,6 +120,18 @@ export function formatLongCurrency(value: number, currency: string, locale = 'en
return formatCurrency(n, currency, locale);
}
+export function decodePunycodeDomain(domain?: string | null) {
+ if (!domain) {
+ return domain;
+ }
+
+ try {
+ return toUnicode(domain);
+ } catch {
+ return domain;
+ }
+}
+
export function truncateString(
value: T,
maxLength: number,