Skip to content

Commit 8e00815

Browse files
committed
Add i18n support and app catalog for personal sites
- Add automatic browser language detection that injects setSiteLanguage step into the boot blueprint - Update defaultBlueprintUrl to point to my-wordpress blueprint on GitHub - Add Install Apps section to menu overlay fetching from apps.json - Add CSS styles for app catalog display
1 parent 7cdbb30 commit 8e00815

File tree

5 files changed

+317
-19
lines changed

5 files changed

+317
-19
lines changed

packages/playground/personal-wp/src/components/menu-overlay/index.tsx

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { useState } from 'react';
22
import { external, trash } from '@wordpress/icons';
33
import { Icon } from '@wordpress/icons';
4+
import { Spinner } from '@wordpress/components';
45
import { logger } from '@php-wasm/logger';
56
import { useActiveSite } from '../../lib/state/redux/store';
67
import { opfsSiteStorage } from '../../lib/state/opfs/opfs-site-storage';
78
import { broadcastSiteReset } from '../../lib/state/redux/tab-coordinator';
89
import { useBackup } from '../../lib/hooks/use-backup';
10+
import useFetch from '../../lib/hooks/use-fetch';
11+
import { WordPressIcon } from '@wp-playground/components';
912
import {
1013
Overlay,
1114
OverlayHeader,
@@ -20,6 +23,25 @@ import {
2023
import { BackupReminder } from '../backup-reminder';
2124
import { TabInfoWindow } from '../tab-info-window';
2225

26+
type AppEntry = {
27+
title: string;
28+
description: string;
29+
author: string;
30+
categories: string[];
31+
};
32+
33+
const APPS_INDEX_URL =
34+
'https://raw.githubusercontent.com/WordPress/blueprints/my-wordpress/apps.json';
35+
const APPS_BASE_URL =
36+
'https://raw.githubusercontent.com/WordPress/blueprints/my-wordpress/';
37+
38+
function getAppBlueprintUrl(blueprintUrl: string): string {
39+
const url = new URL(window.location.href);
40+
url.hash = '';
41+
url.searchParams.set('blueprint-url', blueprintUrl);
42+
return url.toString();
43+
}
44+
2345
interface MenuOverlayProps {
2446
onClose: () => void;
2547
}
@@ -32,6 +54,20 @@ export function MenuOverlay({ onClose }: MenuOverlayProps) {
3254
const [isDeleting, setIsDeleting] = useState(false);
3355
const [showRecoveryButton, setShowRecoveryButton] = useState(false);
3456

57+
const {
58+
data: appsData,
59+
isLoading: appsLoading,
60+
isError: appsError,
61+
} = useFetch<Record<string, AppEntry>>(APPS_INDEX_URL);
62+
63+
const apps = appsData
64+
? Object.entries(appsData).map(([path, entry]) => ({
65+
...entry,
66+
path,
67+
blueprintUrl: `${APPS_BASE_URL}${path}`,
68+
}))
69+
: [];
70+
3571
async function handleStartOver() {
3672
if (!activeSite || activeSite.metadata.storage === 'none') {
3773
return;
@@ -67,15 +103,41 @@ export function MenuOverlay({ onClose }: MenuOverlayProps) {
67103
<OverlayHeader onClose={onClose} />
68104
<OverlayBody>
69105
<TabInfoWindow />
70-
<OverlaySection
71-
title="Personal Playground"
72-
description="Your WordPress data is stored in your browser and will persist across sessions."
73-
>
74-
<p>
75-
This is a personal WordPress installation. Changes you
76-
make will be saved automatically in your browser's
77-
storage.
78-
</p>
106+
107+
<OverlaySection title="Install Apps">
108+
{appsLoading ? (
109+
<div className={css.loadingContainer}>
110+
<Spinner />
111+
</div>
112+
) : appsError ? (
113+
<p className={css.errorMessage}>
114+
Unable to load apps. Check your connection.
115+
</p>
116+
) : (
117+
<div className={css.featuresList}>
118+
{apps.map((app) => (
119+
<a
120+
key={app.path}
121+
className={css.featureItem}
122+
href={getAppBlueprintUrl(app.blueprintUrl)}
123+
>
124+
<span className={css.featureIcon}>
125+
<WordPressIcon />
126+
</span>
127+
<span className={css.featureContent}>
128+
<span className={css.featureTitle}>
129+
{app.title}
130+
</span>
131+
<span
132+
className={css.featureDescription}
133+
>
134+
{app.description}
135+
</span>
136+
</span>
137+
</a>
138+
))}
139+
</div>
140+
)}
79141
</OverlaySection>
80142

81143
<OverlaySection title="Backup">

packages/playground/personal-wp/src/components/menu-overlay/style.module.css

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,80 @@
110110
color: #fff;
111111
text-decoration: none;
112112
}
113+
114+
.loadingContainer {
115+
display: flex;
116+
justify-content: center;
117+
padding: 24px;
118+
}
119+
120+
.errorMessage {
121+
color: #9ca3af;
122+
font-size: clamp(13px, 1.3vw, 14px);
123+
}
124+
125+
.featuresList {
126+
display: flex;
127+
flex-direction: column;
128+
gap: 8px;
129+
}
130+
131+
.featureItem {
132+
display: flex;
133+
align-items: center;
134+
gap: 16px;
135+
padding: 8px 0;
136+
background: none;
137+
border: none;
138+
cursor: pointer;
139+
text-align: left;
140+
color: inherit;
141+
font: inherit;
142+
text-decoration: none;
143+
}
144+
145+
.featureItem:hover {
146+
text-decoration: none;
147+
color: inherit;
148+
}
149+
150+
.featureItem:hover .featureIcon {
151+
background: #3858e9;
152+
color: #fff;
153+
}
154+
155+
.featureIcon {
156+
display: flex;
157+
align-items: center;
158+
justify-content: center;
159+
width: 40px;
160+
height: 40px;
161+
background: #2a3654;
162+
border-radius: 8px;
163+
color: #3858e9;
164+
flex-shrink: 0;
165+
}
166+
167+
.featureIcon svg {
168+
width: 24px;
169+
height: 24px;
170+
}
171+
172+
.featureContent {
173+
display: flex;
174+
flex-direction: column;
175+
gap: 2px;
176+
min-width: 0;
177+
}
178+
179+
.featureTitle {
180+
font-size: clamp(14px, 1.4vw, 15px);
181+
font-weight: 500;
182+
color: #fff;
183+
}
184+
185+
.featureDescription {
186+
font-size: clamp(13px, 1.3vw, 14px);
187+
color: #9ca3af;
188+
line-height: 1.4;
189+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Internationalization support for personal WordPress Playground.
3+
*
4+
* Detects the user's browser language and maps it to a WordPress locale
5+
* for automatic language configuration.
6+
*/
7+
8+
/**
9+
* Common browser language to WordPress locale mappings.
10+
*
11+
* Browser languages use BCP 47 format (e.g., "en-US", "de", "pt-BR")
12+
* WordPress locales use underscore format (e.g., "en_US", "de_DE", "pt_BR")
13+
*
14+
* This map provides explicit mappings for cases where the conversion
15+
* isn't straightforward (e.g., "de" -> "de_DE", not "de").
16+
*/
17+
const BROWSER_TO_WP_LOCALE: Record<string, string> = {
18+
// Languages that need explicit country codes
19+
de: 'de_DE',
20+
fr: 'fr_FR',
21+
es: 'es_ES',
22+
it: 'it_IT',
23+
nl: 'nl_NL',
24+
pl: 'pl_PL',
25+
pt: 'pt_PT',
26+
ru: 'ru_RU',
27+
ja: 'ja',
28+
ko: 'ko_KR',
29+
zh: 'zh_CN',
30+
ar: 'ar',
31+
he: 'he_IL',
32+
tr: 'tr_TR',
33+
sv: 'sv_SE',
34+
da: 'da_DK',
35+
fi: 'fi',
36+
nb: 'nb_NO',
37+
nn: 'nn_NO',
38+
cs: 'cs_CZ',
39+
sk: 'sk_SK',
40+
hu: 'hu_HU',
41+
ro: 'ro_RO',
42+
bg: 'bg_BG',
43+
uk: 'uk',
44+
el: 'el',
45+
th: 'th',
46+
vi: 'vi',
47+
id: 'id_ID',
48+
ms: 'ms_MY',
49+
fa: 'fa_IR',
50+
hi: 'hi_IN',
51+
52+
// Region-specific variants
53+
'pt-br': 'pt_BR',
54+
'zh-tw': 'zh_TW',
55+
'zh-hk': 'zh_HK',
56+
'zh-hans': 'zh_CN',
57+
'zh-hant': 'zh_TW',
58+
'es-mx': 'es_MX',
59+
'es-ar': 'es_AR',
60+
'fr-ca': 'fr_CA',
61+
'fr-be': 'fr_BE',
62+
'nl-be': 'nl_BE',
63+
'de-at': 'de_AT',
64+
'de-ch': 'de_CH',
65+
'en-gb': 'en_GB',
66+
'en-au': 'en_AU',
67+
'en-ca': 'en_CA',
68+
'en-nz': 'en_NZ',
69+
'en-za': 'en_ZA',
70+
};
71+
72+
/**
73+
* Converts a browser language code (BCP 47) to a WordPress locale.
74+
*
75+
* @param browserLang - Browser language code (e.g., "en-US", "de", "pt-BR")
76+
* @returns WordPress locale (e.g., "en_US", "de_DE", "pt_BR") or null if no mapping
77+
*/
78+
export function browserLanguageToWpLocale(browserLang: string): string | null {
79+
const normalized = browserLang.toLowerCase();
80+
81+
// Check for explicit mapping first
82+
if (BROWSER_TO_WP_LOCALE[normalized]) {
83+
return BROWSER_TO_WP_LOCALE[normalized];
84+
}
85+
86+
// Try base language without region
87+
const baseLang = normalized.split('-')[0];
88+
if (baseLang !== normalized && BROWSER_TO_WP_LOCALE[baseLang]) {
89+
return BROWSER_TO_WP_LOCALE[baseLang];
90+
}
91+
92+
// Convert BCP 47 format to WordPress format (en-US -> en_US)
93+
if (normalized.includes('-')) {
94+
const [lang, region] = normalized.split('-');
95+
return `${lang}_${region.toUpperCase()}`;
96+
}
97+
98+
// Single language code without region - return null to indicate
99+
// we should skip language setting (defaults to en_US)
100+
return null;
101+
}
102+
103+
/**
104+
* Gets the user's preferred WordPress locale based on browser settings.
105+
*
106+
* Checks navigator.languages (array of preferred languages) first,
107+
* then falls back to navigator.language.
108+
*
109+
* @returns WordPress locale or null if browser language is English (default)
110+
*/
111+
export function getBrowserWpLocale(): string | null {
112+
const languages =
113+
typeof navigator !== 'undefined'
114+
? navigator.languages || [navigator.language]
115+
: [];
116+
117+
for (const lang of languages) {
118+
if (!lang) continue;
119+
120+
// Skip English variants since that's the default
121+
if (lang.toLowerCase().startsWith('en')) {
122+
return null;
123+
}
124+
125+
const wpLocale = browserLanguageToWpLocale(lang);
126+
if (wpLocale) {
127+
return wpLocale;
128+
}
129+
}
130+
131+
return null;
132+
}
133+
134+
/**
135+
* Creates a setSiteLanguage blueprint step for the browser's language.
136+
*
137+
* @returns A setSiteLanguage step or null if no translation is needed
138+
*/
139+
export function createLanguageStep(): {
140+
step: 'setSiteLanguage';
141+
language: string;
142+
} | null {
143+
const locale = getBrowserWpLocale();
144+
if (!locale) {
145+
return null;
146+
}
147+
return {
148+
step: 'setSiteLanguage',
149+
language: locale,
150+
};
151+
}

0 commit comments

Comments
 (0)