libp2r2p 0.3.0 → 0.5.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.
- package/README.md +55 -1
- package/base36/index.js +6 -6
- package/i18n/index.js +235 -0
- package/index.js +1 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -175,12 +175,66 @@ Use explicit subpath imports for bundle size. The package root re-exports the
|
|
|
175
175
|
main messenger API for convenience, but applications that only need one piece
|
|
176
176
|
should import that subpath directly.
|
|
177
177
|
|
|
178
|
+
## Internationalization
|
|
179
|
+
|
|
180
|
+
The dependency-free `libp2r2p/i18n` subpath exposes locale detection and a
|
|
181
|
+
small translator suitable for keeping each component's translations beside
|
|
182
|
+
that component. Translation keys are literal strings, not dotted paths.
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
import { getT } from 'libp2r2p/i18n'
|
|
186
|
+
|
|
187
|
+
const t = getT({
|
|
188
|
+
'Allow {{size}}': {
|
|
189
|
+
en: 'Allow {{size}}',
|
|
190
|
+
'pt-BR': 'Permitir {{size}}'
|
|
191
|
+
},
|
|
192
|
+
'Delete {{count}} items': {
|
|
193
|
+
en: {
|
|
194
|
+
one: 'Delete {{count}} item',
|
|
195
|
+
other: 'Delete {{count}} items'
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
t('Allow {{size}}', { size: '10 MiB' })
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`getCurrentDeviceLocale()` prefers the locale resolved by `Intl`, then browser
|
|
204
|
+
language hints, and preserves the full canonical BCP 47 locale. `getT()`
|
|
205
|
+
matches exact and compatible language variants, falls back to English and then
|
|
206
|
+
to the key itself, interpolates `{{name}}` values, and uses `Intl.PluralRules`
|
|
207
|
+
when a locale value supplies plural forms. Missing interpolation values remain
|
|
208
|
+
visible in the returned string.
|
|
209
|
+
|
|
210
|
+
Catalog validation is opt-in. `validateLocales()` can be used independently,
|
|
211
|
+
or `getT()` can validate once when creating the translator:
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
import { getT, validateLocales } from 'libp2r2p/i18n'
|
|
215
|
+
|
|
216
|
+
const validation = {
|
|
217
|
+
requiredLocales: ['en', 'pt-BR'],
|
|
218
|
+
referenceLocale: 'en',
|
|
219
|
+
requireReferenceKey: true
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
validateLocales(locales, validation)
|
|
223
|
+
const t = getT(locales, { validation })
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Validation checks catalog structure, required locales, plural `other` forms,
|
|
227
|
+
and placeholder parity across every translation. `requireReferenceKey` also
|
|
228
|
+
requires the reference locale's string, or its plural `other` form, to equal
|
|
229
|
+
the literal translation key. Without `validation`, sparse catalogs continue
|
|
230
|
+
to use the normal locale fallback behavior.
|
|
231
|
+
|
|
178
232
|
## Binary encodings
|
|
179
233
|
|
|
180
234
|
Base16, Base36, Base62, Base64/Base64URL, and Base93 helpers are available
|
|
181
235
|
through their matching `libp2r2p/<encoding>` subpaths. Base36 exposes both a
|
|
182
236
|
binary-safe variable-width codec and the canonical 32-byte/50-character
|
|
183
|
-
|
|
237
|
+
Base36Nsite representation from NIP-5A. Base62 uses the same case-sensitive
|
|
184
238
|
alphabet as app NIP-19 entities; its default byte mode preserves leading zero
|
|
185
239
|
bytes, while integer mode supports fixed-width identifiers.
|
|
186
240
|
|
package/base36/index.js
CHANGED
|
@@ -83,14 +83,14 @@ export function base36ToBase16 (value) {
|
|
|
83
83
|
// NIP-5A treats a raw 32-byte value as one unsigned integer and represents it
|
|
84
84
|
// as exactly 50 lowercase Base36 digits. Its leading zeros are numeric width,
|
|
85
85
|
// not zero-byte markers as they are in the binary-safe codec above.
|
|
86
|
-
export function
|
|
86
|
+
export function bytesToBase36Nsite (bytes) {
|
|
87
87
|
if (bytes.length !== NSITE_BYTE_LENGTH) {
|
|
88
88
|
throw new Error('Nsite Base36 input should be ' + NSITE_BYTE_LENGTH + ' bytes')
|
|
89
89
|
}
|
|
90
90
|
return integerToBase36(bytesToInteger(bytes)).padStart(NSITE_TEXT_LENGTH, LEADER)
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export function
|
|
93
|
+
export function base36NsiteToBytes (value) {
|
|
94
94
|
if (typeof value !== 'string') throw new TypeError('Nsite Base36 value should be a string')
|
|
95
95
|
if (value.length !== NSITE_TEXT_LENGTH) {
|
|
96
96
|
throw new Error('Nsite Base36 value should be ' + NSITE_TEXT_LENGTH + ' characters')
|
|
@@ -105,10 +105,10 @@ export function nsiteBase36ToBytes (value) {
|
|
|
105
105
|
return result
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
export function
|
|
109
|
-
return
|
|
108
|
+
export function base16ToBase36Nsite (value) {
|
|
109
|
+
return bytesToBase36Nsite(base16ToBytes(value))
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
export function
|
|
113
|
-
return bytesToBase16(
|
|
112
|
+
export function base36NsiteToBase16 (value) {
|
|
113
|
+
return bytesToBase16(base36NsiteToBytes(value))
|
|
114
114
|
}
|
package/i18n/index.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
const DEFAULT_LOCALE = 'en'
|
|
2
|
+
const INTERPOLATION_RE = /{{\s*([A-Za-z0-9_.-]+)\s*}}/g
|
|
3
|
+
|
|
4
|
+
function assertLocales (locales) {
|
|
5
|
+
if (!locales || typeof locales !== 'object' || Array.isArray(locales)) {
|
|
6
|
+
throw new TypeError('locales should be an object')
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function placeholderSignature (value) {
|
|
11
|
+
return [...String(value).matchAll(INTERPOLATION_RE)]
|
|
12
|
+
.map(match => match[1])
|
|
13
|
+
.sort()
|
|
14
|
+
.join(',')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function validateTranslationValue (key, locale, value, expectedPlaceholders) {
|
|
18
|
+
if (typeof value === 'string') {
|
|
19
|
+
if (placeholderSignature(value) !== expectedPlaceholders) {
|
|
20
|
+
throw new Error(`placeholder mismatch for "${key}" (${locale})`)
|
|
21
|
+
}
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.other !== 'string') {
|
|
26
|
+
throw new TypeError(`translation for "${key}" (${locale}) should be a string or plural object with an other form`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const form of Object.values(value)) {
|
|
30
|
+
if (typeof form !== 'string') {
|
|
31
|
+
throw new TypeError(`plural forms for "${key}" (${locale}) should be strings`)
|
|
32
|
+
}
|
|
33
|
+
if (placeholderSignature(form) !== expectedPlaceholders) {
|
|
34
|
+
throw new Error(`placeholder mismatch for "${key}" (${locale})`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateLocales (locales, options = {}) {
|
|
40
|
+
assertLocales(locales)
|
|
41
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
42
|
+
throw new TypeError('validation options should be an object')
|
|
43
|
+
}
|
|
44
|
+
const {
|
|
45
|
+
requiredLocales = [],
|
|
46
|
+
referenceLocale = DEFAULT_LOCALE,
|
|
47
|
+
requireReferenceKey = false
|
|
48
|
+
} = options
|
|
49
|
+
if (!Array.isArray(requiredLocales) || requiredLocales.some(locale => typeof locale !== 'string' || !locale)) {
|
|
50
|
+
throw new TypeError('requiredLocales should be an array of non-empty strings')
|
|
51
|
+
}
|
|
52
|
+
if (typeof referenceLocale !== 'string' || !referenceLocale) {
|
|
53
|
+
throw new TypeError('referenceLocale should be a non-empty string')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const [key, translations] of Object.entries(locales)) {
|
|
57
|
+
if (!translations || typeof translations !== 'object' || Array.isArray(translations)) {
|
|
58
|
+
throw new TypeError(`translations for "${key}" should be an object`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const locale of requiredLocales) {
|
|
62
|
+
if (!Object.prototype.hasOwnProperty.call(translations, locale)) {
|
|
63
|
+
throw new Error(`missing translation for "${key}" (${locale})`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const expectedPlaceholders = placeholderSignature(key)
|
|
68
|
+
for (const [locale, value] of Object.entries(translations)) {
|
|
69
|
+
validateTranslationValue(key, locale, value, expectedPlaceholders)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (requireReferenceKey) {
|
|
73
|
+
if (!Object.prototype.hasOwnProperty.call(translations, referenceLocale)) {
|
|
74
|
+
throw new Error(`missing reference translation for "${key}" (${referenceLocale})`)
|
|
75
|
+
}
|
|
76
|
+
const reference = translations[referenceLocale]
|
|
77
|
+
const referenceValue = typeof reference === 'string' ? reference : reference.other
|
|
78
|
+
if (referenceValue !== key) {
|
|
79
|
+
throw new Error(`reference translation should match key "${key}" (${referenceLocale})`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return locales
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function canonicalizeLocale (locale) {
|
|
88
|
+
if (typeof locale !== 'string' || !locale.trim()) return null
|
|
89
|
+
const value = locale.trim().replace(/_/g, '-')
|
|
90
|
+
try {
|
|
91
|
+
return Intl.getCanonicalLocales(value)[0] ?? null
|
|
92
|
+
} catch {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getIntlLocale () {
|
|
98
|
+
try {
|
|
99
|
+
return Intl.DateTimeFormat().resolvedOptions().locale
|
|
100
|
+
} catch {
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getNavigator () {
|
|
106
|
+
try {
|
|
107
|
+
return globalThis.navigator
|
|
108
|
+
} catch {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getCurrentDeviceLocale () {
|
|
114
|
+
const navigator = getNavigator()
|
|
115
|
+
const candidates = [
|
|
116
|
+
getIntlLocale(),
|
|
117
|
+
navigator?.language,
|
|
118
|
+
navigator?.languages?.[0],
|
|
119
|
+
DEFAULT_LOCALE
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
for (const candidate of candidates) {
|
|
123
|
+
const locale = canonicalizeLocale(candidate)
|
|
124
|
+
if (locale) return locale
|
|
125
|
+
}
|
|
126
|
+
return DEFAULT_LOCALE
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function localeLanguage (locale) {
|
|
130
|
+
try {
|
|
131
|
+
return new Intl.Locale(locale).language
|
|
132
|
+
} catch {
|
|
133
|
+
return locale.split('-')[0].toLowerCase()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function preferredChineseLocale (locale) {
|
|
138
|
+
if (localeLanguage(locale) !== 'zh') return null
|
|
139
|
+
try {
|
|
140
|
+
const { script, region } = new Intl.Locale(locale)
|
|
141
|
+
if (script === 'Hant' || ['TW', 'HK', 'MO'].includes(region)) return 'zh-TW'
|
|
142
|
+
return 'zh-CN'
|
|
143
|
+
} catch {
|
|
144
|
+
return /(?:^|-)(?:hant|tw|hk|mo)(?:-|$)/i.test(locale) ? 'zh-TW' : 'zh-CN'
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function localeCandidates (translations, requestedLocale, fallbackLocale) {
|
|
149
|
+
const keys = Object.keys(translations)
|
|
150
|
+
const canonicalKeys = new Map()
|
|
151
|
+
for (const key of keys) {
|
|
152
|
+
const canonical = canonicalizeLocale(key)
|
|
153
|
+
if (canonical && !canonicalKeys.has(canonical.toLowerCase())) {
|
|
154
|
+
canonicalKeys.set(canonical.toLowerCase(), { key, canonical })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = []
|
|
159
|
+
const seen = new Set()
|
|
160
|
+
const add = locale => {
|
|
161
|
+
const canonical = canonicalizeLocale(locale)
|
|
162
|
+
const match = canonical && canonicalKeys.get(canonical.toLowerCase())
|
|
163
|
+
if (match && !seen.has(match.key)) {
|
|
164
|
+
seen.add(match.key)
|
|
165
|
+
result.push(match)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const addCompatible = locale => {
|
|
169
|
+
const canonical = canonicalizeLocale(locale)
|
|
170
|
+
if (!canonical) return
|
|
171
|
+
add(canonical)
|
|
172
|
+
add(preferredChineseLocale(canonical))
|
|
173
|
+
const language = localeLanguage(canonical)
|
|
174
|
+
const match = [...canonicalKeys.values()].find(candidate => localeLanguage(candidate.canonical) === language)
|
|
175
|
+
if (match) add(match.canonical)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
addCompatible(requestedLocale)
|
|
179
|
+
addCompatible(fallbackLocale)
|
|
180
|
+
addCompatible(DEFAULT_LOCALE)
|
|
181
|
+
return result
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function selectPlural (forms, locale, values) {
|
|
185
|
+
if (!forms || typeof forms !== 'object' || Array.isArray(forms)) return null
|
|
186
|
+
const count = Number(values?.count)
|
|
187
|
+
if (!Number.isFinite(count)) return null
|
|
188
|
+
|
|
189
|
+
let category = 'other'
|
|
190
|
+
try {
|
|
191
|
+
category = new Intl.PluralRules(locale).select(count)
|
|
192
|
+
} catch {}
|
|
193
|
+
const value = forms[category] ?? forms.other
|
|
194
|
+
return typeof value === 'string' ? value : null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function selectTemplate (translations, requestedLocale, fallbackLocale, values) {
|
|
198
|
+
if (!translations || typeof translations !== 'object' || Array.isArray(translations)) return null
|
|
199
|
+
for (const { key, canonical } of localeCandidates(translations, requestedLocale, fallbackLocale)) {
|
|
200
|
+
const value = translations[key]
|
|
201
|
+
if (typeof value === 'string') return value
|
|
202
|
+
const plural = selectPlural(value, canonical, values)
|
|
203
|
+
if (plural !== null) return plural
|
|
204
|
+
}
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function interpolate (template, values) {
|
|
209
|
+
const source = String(template)
|
|
210
|
+
if (!values || typeof values !== 'object') return source
|
|
211
|
+
return source.replace(INTERPOLATION_RE, (token, name) => (
|
|
212
|
+
Object.prototype.hasOwnProperty.call(values, name)
|
|
213
|
+
? String(values[name])
|
|
214
|
+
: token
|
|
215
|
+
))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function getT (locales, {
|
|
219
|
+
locale = getCurrentDeviceLocale(),
|
|
220
|
+
fallbackLocale = DEFAULT_LOCALE,
|
|
221
|
+
validation
|
|
222
|
+
} = {}) {
|
|
223
|
+
assertLocales(locales)
|
|
224
|
+
if (validation !== undefined) validateLocales(locales, validation)
|
|
225
|
+
|
|
226
|
+
const requestedLocale = canonicalizeLocale(locale) ?? DEFAULT_LOCALE
|
|
227
|
+
const canonicalFallback = canonicalizeLocale(fallbackLocale) ?? DEFAULT_LOCALE
|
|
228
|
+
|
|
229
|
+
return function t (key, values) {
|
|
230
|
+
if (typeof key !== 'string') throw new TypeError('translation key should be a string')
|
|
231
|
+
const translations = Object.prototype.hasOwnProperty.call(locales, key) ? locales[key] : null
|
|
232
|
+
const template = selectTemplate(translations, requestedLocale, canonicalFallback, values) ?? key
|
|
233
|
+
return interpolate(template, values)
|
|
234
|
+
}
|
|
235
|
+
}
|
package/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export * as doubleDh from './double-dh/index.js'
|
|
|
10
10
|
export * as ecdh from './ecdh/index.js'
|
|
11
11
|
export * as idb from './idb/index.js'
|
|
12
12
|
export * as idbQueue from './idb-queue/index.js'
|
|
13
|
+
export * as i18n from './i18n/index.js'
|
|
13
14
|
export * as key from './key/index.js'
|
|
14
15
|
export * as network from './network/index.js'
|
|
15
16
|
export * as nip19 from './nip19/index.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "libp2r2p",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Peer-to-relay-to-peer",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"p2r2p",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"ecdh",
|
|
23
23
|
"idb",
|
|
24
24
|
"idb-queue",
|
|
25
|
+
"i18n",
|
|
25
26
|
"index.js",
|
|
26
27
|
"key",
|
|
27
28
|
"network",
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"./ecdh": "./ecdh/index.js",
|
|
53
54
|
"./idb": "./idb/index.js",
|
|
54
55
|
"./idb-queue": "./idb-queue/index.js",
|
|
56
|
+
"./i18n": "./i18n/index.js",
|
|
55
57
|
"./key": "./key/index.js",
|
|
56
58
|
"./network": "./network/index.js",
|
|
57
59
|
"./nip19": "./nip19/index.js",
|