@asafarim/shared-i18n 0.8.0 → 0.9.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 (94) hide show
  1. package/README.md +266 -280
  2. package/demo/README.md +119 -0
  3. package/demo/index.html +12 -1
  4. package/demo/node_modules/.bin/kill-port +17 -0
  5. package/demo/node_modules/.bin/tsc +5 -9
  6. package/demo/node_modules/.bin/tsserver +5 -9
  7. package/demo/node_modules/.bin/vite +5 -9
  8. package/demo/package.json +7 -4
  9. package/demo/public/404.html +24 -0
  10. package/demo/public/favicon.svg +4 -4
  11. package/demo/public/logo.svg +24 -24
  12. package/demo/src/App.tsx +178 -129
  13. package/demo/src/components/CountryLanguageSelectorsPage.tsx +240 -0
  14. package/demo/src/components/GetStartedSection.tsx +56 -56
  15. package/demo/src/components/KeyTable.tsx +29 -29
  16. package/demo/src/components/LanguageBar.tsx +145 -103
  17. package/demo/src/components/LanguageSwitcherDemo.module.css +114 -114
  18. package/demo/src/components/LanguageSwitchersPage.tsx +245 -0
  19. package/demo/src/components/Logo.tsx +6 -6
  20. package/demo/src/components/OverviewSection.tsx +58 -43
  21. package/demo/src/components/Panel.tsx +15 -15
  22. package/demo/src/components/RoutingLabPage.tsx +147 -0
  23. package/demo/src/components/StatusCard.tsx +109 -109
  24. package/demo/src/data/countries.ts +48 -0
  25. package/demo/src/i18n/localeAdapter.ts +91 -0
  26. package/demo/src/i18n/localeRouting.ts +77 -0
  27. package/demo/src/index.css +1075 -644
  28. package/demo/src/locales/de/demo.json +202 -84
  29. package/demo/src/locales/en/demo.json +201 -85
  30. package/demo/src/locales/fr/demo.json +203 -85
  31. package/demo/src/locales/it/demo.json +202 -84
  32. package/demo/src/locales/lb/demo.json +201 -0
  33. package/demo/src/locales/nl/demo.json +203 -85
  34. package/demo/src/main.tsx +32 -29
  35. package/demo/tsconfig.json +18 -18
  36. package/demo/tsconfig.node.json +10 -10
  37. package/demo/tsconfig.tsbuildinfo +1 -1
  38. package/demo/vite-env.d.ts +7 -7
  39. package/demo/vite.config.d.ts +2 -2
  40. package/demo/vite.config.js +10 -10
  41. package/dist/components/LanguageSwitcher.module.css +303 -303
  42. package/dist/country-language-selector.css +431 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -0
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/package.json +87 -85
  48. package/demo/dist/Icon Dropdown_Limited Languages.png +0 -0
  49. package/demo/dist/Select Dropdown_Text Only.png +0 -0
  50. package/demo/dist/assets/favicon-BZYZvBLo.svg +0 -4
  51. package/demo/dist/assets/index-BdjqKw_N.css +0 -1
  52. package/demo/dist/assets/index-C1Tq1uEr.js +0 -191
  53. package/demo/dist/favicon.svg +0 -4
  54. package/demo/dist/index.html +0 -27
  55. package/demo/dist/logo.svg +0 -24
  56. package/demo/node_modules/.bin/browserslist +0 -21
  57. package/demo/node_modules/.bin/browserslist.CMD +0 -12
  58. package/demo/node_modules/.bin/browserslist.ps1 +0 -41
  59. package/demo/node_modules/.bin/tsc.CMD +0 -12
  60. package/demo/node_modules/.bin/tsc.ps1 +0 -41
  61. package/demo/node_modules/.bin/tsserver.CMD +0 -12
  62. package/demo/node_modules/.bin/tsserver.ps1 +0 -41
  63. package/demo/node_modules/.bin/vite.CMD +0 -12
  64. package/demo/node_modules/.bin/vite.ps1 +0 -41
  65. package/demo/node_modules/.vite/deps/@asafarim_country-language-selector.js +0 -848
  66. package/demo/node_modules/.vite/deps/@asafarim_country-language-selector.js.map +0 -7
  67. package/demo/node_modules/.vite/deps/_metadata.json +0 -76
  68. package/demo/node_modules/.vite/deps/chunk-5WRI5ZAA.js +0 -30
  69. package/demo/node_modules/.vite/deps/chunk-5WRI5ZAA.js.map +0 -7
  70. package/demo/node_modules/.vite/deps/chunk-B3AHR5EX.js +0 -1004
  71. package/demo/node_modules/.vite/deps/chunk-B3AHR5EX.js.map +0 -7
  72. package/demo/node_modules/.vite/deps/chunk-E6BG6WAU.js +0 -292
  73. package/demo/node_modules/.vite/deps/chunk-E6BG6WAU.js.map +0 -7
  74. package/demo/node_modules/.vite/deps/chunk-MVARZQEG.js +0 -280
  75. package/demo/node_modules/.vite/deps/chunk-MVARZQEG.js.map +0 -7
  76. package/demo/node_modules/.vite/deps/i18next-browser-languagedetector.js +0 -400
  77. package/demo/node_modules/.vite/deps/i18next-browser-languagedetector.js.map +0 -7
  78. package/demo/node_modules/.vite/deps/i18next.js +0 -2392
  79. package/demo/node_modules/.vite/deps/i18next.js.map +0 -7
  80. package/demo/node_modules/.vite/deps/package.json +0 -3
  81. package/demo/node_modules/.vite/deps/react-dom.js +0 -6
  82. package/demo/node_modules/.vite/deps/react-dom.js.map +0 -7
  83. package/demo/node_modules/.vite/deps/react-dom_client.js +0 -20217
  84. package/demo/node_modules/.vite/deps/react-dom_client.js.map +0 -7
  85. package/demo/node_modules/.vite/deps/react-i18next.js +0 -869
  86. package/demo/node_modules/.vite/deps/react-i18next.js.map +0 -7
  87. package/demo/node_modules/.vite/deps/react.js +0 -5
  88. package/demo/node_modules/.vite/deps/react.js.map +0 -7
  89. package/demo/node_modules/.vite/deps/react_jsx-dev-runtime.js +0 -278
  90. package/demo/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +0 -7
  91. package/demo/node_modules/.vite/deps/react_jsx-runtime.js +0 -6
  92. package/demo/node_modules/.vite/deps/react_jsx-runtime.js.map +0 -7
  93. package/demo/src/components/CountryLanguageDemo.tsx +0 -140
  94. package/demo/src/components/LanguageSwitcherDemo.tsx +0 -256
@@ -1,109 +1,109 @@
1
- import { useState, useEffect } from 'react'
2
- import {
3
- useTranslation,
4
- SUPPORTED_LANGUAGES,
5
- LANGUAGE_NAMES,
6
- LANGUAGE_COOKIE_NAME,
7
- getLanguageFromCookie,
8
- getInitialLanguage,
9
- updateUserLanguagePreference,
10
- isSupportedLanguage
11
- } from '@asafarim/shared-i18n'
12
- import Panel from './Panel'
13
-
14
- export default function StatusCard() {
15
- const { t, i18n } = useTranslation('demo')
16
- const [cookieValue, setCookieValue] = useState<string | null>(null)
17
- const [initialLang, setInitialLang] = useState<string>('')
18
- const [syncResult, setSyncResult] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
19
- const [isSyncing, setIsSyncing] = useState(false)
20
-
21
- useEffect(() => {
22
- setCookieValue(getLanguageFromCookie())
23
- setInitialLang(getInitialLanguage())
24
- }, [i18n.language])
25
-
26
- const backendUrl = import.meta.env.VITE_IDENTITY_API_URL || 'not set'
27
-
28
- const handleSync = async () => {
29
- setIsSyncing(true)
30
- setSyncResult(null)
31
- try {
32
- const currentLanguage = i18n.language
33
- if (isSupportedLanguage(currentLanguage)) {
34
- await updateUserLanguagePreference(currentLanguage)
35
- } else {
36
- throw new Error(`Unsupported language: ${currentLanguage}`)
37
- }
38
- setSyncResult({
39
- type: 'success',
40
- message: t('status.resultOk')
41
- })
42
- } catch (error) {
43
- setSyncResult({
44
- type: 'error',
45
- message: `${t('status.resultFail')}: ${error instanceof Error ? error.message : 'Unknown error'}`
46
- })
47
- } finally {
48
- setIsSyncing(false)
49
- }
50
- }
51
-
52
- return (
53
- <Panel title={t('status.heading')}>
54
- <div className="status-grid">
55
- <div className="status-card">
56
- <h4>Current Language</h4>
57
- <p>{i18n.language}</p>
58
- </div>
59
-
60
- <div className="status-card">
61
- <h4>{t('status.cookie')}</h4>
62
- <p>
63
- <strong>Name:</strong> {LANGUAGE_COOKIE_NAME}
64
- </p>
65
- <p>
66
- <strong>Value:</strong> {cookieValue || '(not set)'}
67
- </p>
68
- </div>
69
-
70
- <div className="status-card">
71
- <h4>Initial Language</h4>
72
- <p>{initialLang}</p>
73
- </div>
74
-
75
- <div className="status-card">
76
- <h4>Supported Languages</h4>
77
- <ul className="status-list">
78
- {SUPPORTED_LANGUAGES.map((lang: string) => (
79
- <li key={lang}>
80
- {lang}: {LANGUAGE_NAMES[lang as keyof typeof LANGUAGE_NAMES]}
81
- </li>
82
- ))}
83
- </ul>
84
- </div>
85
-
86
- <div className="status-card">
87
- <h4>{t('status.backend')}</h4>
88
- <p>{backendUrl}</p>
89
- </div>
90
-
91
- <div className="status-card">
92
- <h4>Backend Sync</h4>
93
- <button
94
- className="sync-button"
95
- onClick={handleSync}
96
- disabled={isSyncing}
97
- >
98
- {isSyncing ? 'Syncing...' : t('status.simulate')}
99
- </button>
100
- {syncResult && (
101
- <div className={`sync-result ${syncResult.type}`}>
102
- {syncResult.message}
103
- </div>
104
- )}
105
- </div>
106
- </div>
107
- </Panel>
108
- )
109
- }
1
+ import { useState, useEffect } from 'react'
2
+ import {
3
+ useTranslation,
4
+ SUPPORTED_LANGUAGES,
5
+ LANGUAGE_NAMES,
6
+ LANGUAGE_COOKIE_NAME,
7
+ getLanguageFromCookie,
8
+ getInitialLanguage,
9
+ updateUserLanguagePreference,
10
+ isSupportedLanguage
11
+ } from '@asafarim/shared-i18n'
12
+ import Panel from './Panel'
13
+
14
+ export default function StatusCard() {
15
+ const { t, i18n } = useTranslation('demo')
16
+ const [cookieValue, setCookieValue] = useState<string | null>(null)
17
+ const [initialLang, setInitialLang] = useState<string>('')
18
+ const [syncResult, setSyncResult] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
19
+ const [isSyncing, setIsSyncing] = useState(false)
20
+
21
+ useEffect(() => {
22
+ setCookieValue(getLanguageFromCookie())
23
+ setInitialLang(getInitialLanguage())
24
+ }, [i18n.language])
25
+
26
+ const backendUrl = import.meta.env.VITE_IDENTITY_API_URL || 'not set'
27
+
28
+ const handleSync = async () => {
29
+ setIsSyncing(true)
30
+ setSyncResult(null)
31
+ try {
32
+ const currentLanguage = i18n.language
33
+ if (isSupportedLanguage(currentLanguage)) {
34
+ await updateUserLanguagePreference(currentLanguage)
35
+ } else {
36
+ throw new Error(`Unsupported language: ${currentLanguage}`)
37
+ }
38
+ setSyncResult({
39
+ type: 'success',
40
+ message: t('status.resultOk')
41
+ })
42
+ } catch (error) {
43
+ setSyncResult({
44
+ type: 'error',
45
+ message: `${t('status.resultFail')}: ${error instanceof Error ? error.message : 'Unknown error'}`
46
+ })
47
+ } finally {
48
+ setIsSyncing(false)
49
+ }
50
+ }
51
+
52
+ return (
53
+ <Panel title={t('status.heading')}>
54
+ <div className="status-grid">
55
+ <div className="status-card">
56
+ <h4>Current Language</h4>
57
+ <p>{i18n.language}</p>
58
+ </div>
59
+
60
+ <div className="status-card">
61
+ <h4>{t('status.cookie')}</h4>
62
+ <p>
63
+ <strong>Name:</strong> {LANGUAGE_COOKIE_NAME}
64
+ </p>
65
+ <p>
66
+ <strong>Value:</strong> {cookieValue || '(not set)'}
67
+ </p>
68
+ </div>
69
+
70
+ <div className="status-card">
71
+ <h4>Initial Language</h4>
72
+ <p>{initialLang}</p>
73
+ </div>
74
+
75
+ <div className="status-card">
76
+ <h4>Supported Languages</h4>
77
+ <ul className="status-list">
78
+ {SUPPORTED_LANGUAGES.map((lang: string) => (
79
+ <li key={lang}>
80
+ {lang}: {LANGUAGE_NAMES[lang as keyof typeof LANGUAGE_NAMES]}
81
+ </li>
82
+ ))}
83
+ </ul>
84
+ </div>
85
+
86
+ <div className="status-card">
87
+ <h4>{t('status.backend')}</h4>
88
+ <p>{backendUrl}</p>
89
+ </div>
90
+
91
+ <div className="status-card">
92
+ <h4>Backend Sync</h4>
93
+ <button
94
+ className="sync-button"
95
+ onClick={handleSync}
96
+ disabled={isSyncing}
97
+ >
98
+ {isSyncing ? 'Syncing...' : t('status.simulate')}
99
+ </button>
100
+ {syncResult && (
101
+ <div className={`sync-result ${syncResult.type}`}>
102
+ {syncResult.message}
103
+ </div>
104
+ )}
105
+ </div>
106
+ </div>
107
+ </Panel>
108
+ )
109
+ }
@@ -0,0 +1,48 @@
1
+ import type { Country } from '@asafarim/shared-i18n'
2
+
3
+ /** Benelux + United Kingdom — the scope for this demo. */
4
+ export const countries: Country[] = [
5
+ {
6
+ code: 'BE',
7
+ name: 'Belgium',
8
+ nativeName: 'België',
9
+ flag: '🇧🇪',
10
+ languages: [
11
+ { code: 'en', label: 'English', nativeLabel: 'English' },
12
+ { code: 'nl', label: 'Dutch', nativeLabel: 'Nederlands' },
13
+ { code: 'fr', label: 'French', nativeLabel: 'Français' },
14
+ { code: 'de', label: 'German', nativeLabel: 'Deutsch' }
15
+ ]
16
+ },
17
+ {
18
+ code: 'NL',
19
+ name: 'Netherlands',
20
+ nativeName: 'Nederland',
21
+ flag: '��',
22
+ languages: [
23
+ { code: 'nl', label: 'Dutch', nativeLabel: 'Nederlands' },
24
+ { code: 'en', label: 'English', nativeLabel: 'English' }
25
+ ]
26
+ },
27
+ {
28
+ code: 'LU',
29
+ name: 'Luxembourg',
30
+ nativeName: 'Lëtzebuerg',
31
+ flag: '🇱🇺',
32
+ languages: [
33
+ { code: 'lb', label: 'Luxembourgish', nativeLabel: 'Lëtzebuergesch' },
34
+ { code: 'fr', label: 'French', nativeLabel: 'Français' },
35
+ { code: 'de', label: 'German', nativeLabel: 'Deutsch' },
36
+ { code: 'en', label: 'English', nativeLabel: 'English' }
37
+ ]
38
+ },
39
+ {
40
+ code: 'GB',
41
+ name: 'United Kingdom',
42
+ nativeName: 'United Kingdom',
43
+ flag: '��',
44
+ languages: [
45
+ { code: 'en', label: 'English', nativeLabel: 'English' }
46
+ ]
47
+ }
48
+ ]
@@ -0,0 +1,91 @@
1
+ import type { Locale, Country } from '@asafarim/shared-i18n'
2
+
3
+ export type ResolveReason = 'same-country' | 'fallback-country' | 'unsupported'
4
+
5
+ export interface ResolveLocaleResult {
6
+ locale: Locale
7
+ reason: ResolveReason
8
+ /** Human-readable notice when a fallback was applied. */
9
+ message?: string
10
+ }
11
+
12
+ /**
13
+ * Preferred first-choice country code for each language when the current
14
+ * country does not support the selected language.
15
+ * If the language isn't listed here, we fall through to the first country
16
+ * in `countries` that supports it.
17
+ */
18
+ const PREFERRED_FALLBACK: Record<string, string> = {
19
+ nl: 'NL',
20
+ fr: 'BE',
21
+ de: 'BE',
22
+ lb: 'LU',
23
+ en: 'BE',
24
+ }
25
+
26
+ /**
27
+ * Resolve a full Locale from a language-only change.
28
+ *
29
+ * Rules:
30
+ * 1. If `currentLocale.country` supports `nextLanguage` → keep country.
31
+ * 2. Else find the preferred fallback country (see `PREFERRED_FALLBACK`),
32
+ * then any other country that supports it.
33
+ * 3. If no country supports the language → return current locale unchanged.
34
+ */
35
+ export function resolveLocaleFromLanguage(
36
+ currentLocale: Locale,
37
+ nextLanguage: string,
38
+ countries: Country[],
39
+ ): ResolveLocaleResult {
40
+ const lang = nextLanguage.toLowerCase()
41
+
42
+ // 1. Current country already supports the language?
43
+ const currentCountry = countries.find(
44
+ c => c.code.toUpperCase() === currentLocale.country.toUpperCase(),
45
+ )
46
+ if (currentCountry?.languages.some(l => l.code.toLowerCase() === lang)) {
47
+ return {
48
+ locale: { country: currentLocale.country, language: lang },
49
+ reason: 'same-country',
50
+ }
51
+ }
52
+
53
+ // 2. Try preferred fallback country.
54
+ const preferredCode = PREFERRED_FALLBACK[lang]
55
+ if (preferredCode) {
56
+ const preferred = countries.find(
57
+ c => c.code.toUpperCase() === preferredCode.toUpperCase(),
58
+ )
59
+ if (preferred?.languages.some(l => l.code.toLowerCase() === lang)) {
60
+ const fromCountryName = currentCountry?.name ?? currentLocale.country
61
+ const langLabel = preferred.languages.find(l => l.code.toLowerCase() === lang)?.label ?? lang
62
+ return {
63
+ locale: { country: preferred.code, language: lang },
64
+ reason: 'fallback-country',
65
+ message: `${langLabel} is not available for ${fromCountryName}, so the locale moved to ${preferred.name}.`,
66
+ }
67
+ }
68
+ }
69
+
70
+ // 3. Any country that supports it.
71
+ for (const country of countries) {
72
+ if (country.languages.some(l => l.code.toLowerCase() === lang)) {
73
+ const fromCountryName = currentCountry?.name ?? currentLocale.country
74
+ const langLabel = country.languages.find(l => l.code.toLowerCase() === lang)?.label ?? lang
75
+ return {
76
+ locale: { country: country.code, language: lang },
77
+ reason: 'fallback-country',
78
+ message: `${langLabel} is not available for ${fromCountryName}, so the locale moved to ${country.name}.`,
79
+ }
80
+ }
81
+ }
82
+
83
+ // 4. Unsupported — no country found.
84
+ const langLabel = nextLanguage
85
+ const fromCountryName = currentCountry?.name ?? currentLocale.country
86
+ return {
87
+ locale: currentLocale,
88
+ reason: 'unsupported',
89
+ message: `${langLabel} is not supported by any country in this demo. Keeping ${fromCountryName}.`,
90
+ }
91
+ }
@@ -0,0 +1,77 @@
1
+ import type { Locale } from '@asafarim/shared-i18n'
2
+ import { countries } from '../data/countries'
3
+
4
+ export type DemoPage =
5
+ | 'overview'
6
+ | 'get-started'
7
+ | 'language-switchers'
8
+ | 'country-language-selectors'
9
+ | 'routing-lab'
10
+
11
+ export const PAGES: DemoPage[] = [
12
+ 'overview',
13
+ 'get-started',
14
+ 'language-switchers',
15
+ 'country-language-selectors',
16
+ 'routing-lab',
17
+ ]
18
+
19
+ export const DEFAULT_LOCALE: Locale = { country: 'BE', language: 'en' }
20
+ export const DEFAULT_PAGE: DemoPage = 'overview'
21
+
22
+ /** Vite base path. Mirrors `vite.config.ts` `base`. */
23
+ export const BASE_PATH = '/shared-i18n'
24
+
25
+ export function isSupportedLocale(locale: Locale): boolean {
26
+ const country = countries.find(c => c.code.toUpperCase() === locale.country.toUpperCase())
27
+ if (!country) return false
28
+ return country.languages.some(l => l.code.toLowerCase() === locale.language.toLowerCase())
29
+ }
30
+
31
+ export function isSupportedPage(page: string): page is DemoPage {
32
+ return (PAGES as string[]).includes(page)
33
+ }
34
+
35
+ export function localeToSlug(locale: Locale): string {
36
+ return `${locale.country.toLowerCase()}-${locale.language.toLowerCase()}`
37
+ }
38
+
39
+ export function slugToLocale(slug: string): Locale | null {
40
+ const match = /^([a-z]{2})-([a-z]{2,3})$/i.exec(slug.trim())
41
+ if (!match) return null
42
+ const candidate: Locale = { country: match[1].toUpperCase(), language: match[2].toLowerCase() }
43
+ return isSupportedLocale(candidate) ? candidate : null
44
+ }
45
+
46
+ /** Build a path like `/shared-i18n/be-en/overview`. */
47
+ export function buildLocalizedPath(locale: Locale, page: DemoPage): string {
48
+ return `${BASE_PATH}/${localeToSlug(locale)}/${page}`
49
+ }
50
+
51
+ export interface ParsedRoute {
52
+ locale: Locale
53
+ page: DemoPage
54
+ /** True if the input did not cleanly map to a known locale + page. */
55
+ needsRedirect: boolean
56
+ }
57
+
58
+ /**
59
+ * Parse a window.location.pathname into a `{ locale, page }` route.
60
+ * Falls back to `DEFAULT_LOCALE` / `DEFAULT_PAGE` and signals redirect when needed.
61
+ */
62
+ export function parsePathname(pathname: string): ParsedRoute {
63
+ let path = pathname
64
+ if (path.startsWith(BASE_PATH)) path = path.slice(BASE_PATH.length)
65
+ path = path.replace(/^\/+/, '').replace(/\/+$/, '')
66
+
67
+ if (!path) {
68
+ return { locale: DEFAULT_LOCALE, page: DEFAULT_PAGE, needsRedirect: true }
69
+ }
70
+
71
+ const [slugPart, pagePart] = path.split('/')
72
+ const parsedLocale = slugToLocale(slugPart)
73
+ const locale = parsedLocale ?? DEFAULT_LOCALE
74
+ const page = pagePart && isSupportedPage(pagePart) ? pagePart : DEFAULT_PAGE
75
+ const needsRedirect = !parsedLocale || !pagePart || !isSupportedPage(pagePart)
76
+ return { locale, page, needsRedirect }
77
+ }