@open-mercato/shared 0.8.1-develop.7274.1.21a0083828 → 0.8.1-develop.7294.1.0ef99fc518

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 (45) hide show
  1. package/.turbo/turbo-build.log +2 -1
  2. package/build.mjs +4 -1
  3. package/dist/lib/crud/errors.js +6 -1
  4. package/dist/lib/crud/errors.js.map +2 -2
  5. package/dist/lib/crud/factory.js +22 -13
  6. package/dist/lib/crud/factory.js.map +2 -2
  7. package/dist/lib/custom-fields/kinds.js +113 -0
  8. package/dist/lib/custom-fields/kinds.js.map +7 -0
  9. package/dist/lib/encryption/indexDoc.js +12 -9
  10. package/dist/lib/encryption/indexDoc.js.map +2 -2
  11. package/dist/lib/i18n/server.js +1 -1
  12. package/dist/lib/i18n/server.js.map +2 -2
  13. package/dist/lib/location/countries.generated.js +265 -0
  14. package/dist/lib/location/countries.generated.js.map +7 -0
  15. package/dist/lib/location/countries.js +2 -13
  16. package/dist/lib/location/countries.js.map +2 -2
  17. package/dist/lib/navigation/pageReload.js +11 -0
  18. package/dist/lib/navigation/pageReload.js.map +7 -0
  19. package/dist/lib/schedule/interval.js +33 -0
  20. package/dist/lib/schedule/interval.js.map +7 -0
  21. package/dist/lib/schedule/invalidScheduleValue.js +19 -0
  22. package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
  23. package/dist/lib/version.js +1 -1
  24. package/dist/lib/version.js.map +1 -1
  25. package/package.json +3 -2
  26. package/scripts/generate-countries.mjs +75 -0
  27. package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
  28. package/src/lib/crud/__tests__/errors.test.ts +31 -1
  29. package/src/lib/crud/errors.ts +16 -0
  30. package/src/lib/crud/factory.ts +37 -15
  31. package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
  32. package/src/lib/custom-fields/kinds.ts +205 -0
  33. package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
  34. package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
  35. package/src/lib/encryption/indexDoc.ts +16 -7
  36. package/src/lib/i18n/__tests__/dictionary-locale-fallback.test.ts +49 -0
  37. package/src/lib/i18n/server.ts +13 -5
  38. package/src/lib/location/__tests__/countries.test.ts +25 -0
  39. package/src/lib/location/countries.generated.ts +264 -0
  40. package/src/lib/location/countries.ts +2 -23
  41. package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
  42. package/src/lib/navigation/pageReload.ts +43 -0
  43. package/src/lib/schedule/__tests__/interval.test.ts +37 -0
  44. package/src/lib/schedule/interval.ts +45 -0
  45. package/src/lib/schedule/invalidScheduleValue.ts +33 -0
@@ -39,6 +39,45 @@ describe('encryption/indexDoc', () => {
39
39
  expect(decryptCustomFieldValueMock).toHaveBeenCalled()
40
40
  })
41
41
 
42
+ test('decryptIndexDocCustomFields passes each key its resolved kind (#5968)', async () => {
43
+ const doc = { id: '1', 'cf:note': 'enc', 'cf:count': 'enc', cf_order_ref: 'enc' }
44
+ const kinds = { note: 'text', count: 'integer', 'order-ref': 'text', order_ref: 'text' }
45
+
46
+ await decryptIndexDocCustomFields(doc, { tenantId: 't1', organizationId: 'org1' }, {} as any, undefined, kinds)
47
+
48
+ expect(decryptCustomFieldValueMock).toHaveBeenCalledTimes(3)
49
+ const kindsPassed = decryptCustomFieldValueMock.mock.calls.map((call) => call[4]?.kind).sort()
50
+ expect(kindsPassed).toEqual(['integer', 'text', 'text'])
51
+ })
52
+
53
+ test('decryptIndexDocCustomFields passes a null kind when no map is supplied (#5968)', async () => {
54
+ const doc = { id: '1', 'cf:note': 'enc' }
55
+
56
+ await decryptIndexDocCustomFields(doc, { tenantId: 't1', organizationId: 'org1' }, {} as any)
57
+
58
+ expect(decryptCustomFieldValueMock).toHaveBeenCalledWith('enc', 't1', {}, undefined, { kind: null })
59
+ })
60
+
61
+ test('decryptIndexDocForSearch forwards the kind map to the cf decryption (#5968)', async () => {
62
+ const service = {
63
+ isEnabled: () => true,
64
+ decryptEntityPayload: jest.fn(async () => ({})),
65
+ }
66
+
67
+ await decryptIndexDocForSearch(
68
+ 'example:todo',
69
+ { id: '1', 'cf:note': 'enc' },
70
+ { tenantId: 't1', organizationId: 'org1' },
71
+ service as any,
72
+ undefined,
73
+ { note: 'multiline' },
74
+ )
75
+
76
+ expect(decryptCustomFieldValueMock).toHaveBeenCalledWith(
77
+ 'enc', 't1', service, undefined, { kind: 'multiline' },
78
+ )
79
+ })
80
+
42
81
  test('decryptIndexDocForSearch merges decrypted entity payload and decrypts cf keys', async () => {
43
82
  decryptCustomFieldValueMock.mockImplementation(async (value: unknown) => (value === 'enc' ? 'dec' : value))
44
83
 
@@ -1,4 +1,5 @@
1
1
  import { decryptCustomFieldValue } from './customFieldValues'
2
+ import { resolveCustomFieldKind, type CustomFieldKindMap } from '../custom-fields/kinds'
2
3
  import type { TenantDataEncryptionService } from './tenantDataEncryptionService'
3
4
 
4
5
  export type IndexDocScope = {
@@ -10,12 +11,15 @@ async function decryptValue(
10
11
  value: unknown,
11
12
  scope: IndexDocScope,
12
13
  service: TenantDataEncryptionService | null,
13
- cache?: Map<string | null, string | null>,
14
+ cache: Map<string | null, string | null> | undefined,
15
+ kind: string | null,
14
16
  ): Promise<unknown> {
15
17
  if (Array.isArray(value)) {
16
- return Promise.all(value.map((entry) => decryptCustomFieldValue(entry, scope.tenantId, service, cache)))
18
+ return Promise.all(
19
+ value.map((entry) => decryptCustomFieldValue(entry, scope.tenantId, service, cache, { kind })),
20
+ )
17
21
  }
18
- return decryptCustomFieldValue(value, scope.tenantId, service, cache)
22
+ return decryptCustomFieldValue(value, scope.tenantId, service, cache, { kind })
19
23
  }
20
24
 
21
25
  export async function decryptIndexDocCustomFields(
@@ -23,6 +27,7 @@ export async function decryptIndexDocCustomFields(
23
27
  scope: IndexDocScope,
24
28
  service: TenantDataEncryptionService | null,
25
29
  cache?: Map<string | null, string | null>,
30
+ kinds?: CustomFieldKindMap | null,
26
31
  ): Promise<Record<string, unknown>> {
27
32
  // HybridQueryEngine aliases cf keys as `cf_<key>` (sanitized), while index docs use `cf:<key>`.
28
33
  // Support both shapes to keep decryption consistent across query paths.
@@ -33,7 +38,10 @@ export async function decryptIndexDocCustomFields(
33
38
  await Promise.all(
34
39
  keys.map(async (key) => {
35
40
  try {
36
- working[key] = await decryptValue(working[key], scope, service, cache)
41
+ // Without the field's kind, `decryptCustomFieldValue` falls back to `JSON.parse` and
42
+ // retypes string-typed values whose plaintext looks like JSON — `"123"` becomes 123
43
+ // and `"true"` becomes true (issue #5968). An unresolved kind keeps the legacy path.
44
+ working[key] = await decryptValue(working[key], scope, service, cache, resolveCustomFieldKind(kinds, key))
37
45
  } catch {
38
46
  // ignore; keep original value
39
47
  }
@@ -48,12 +56,13 @@ export async function decryptIndexDocForSearch(
48
56
  scope: IndexDocScope,
49
57
  service: TenantDataEncryptionService | null,
50
58
  cache?: Map<string | null, string | null>,
59
+ kinds?: CustomFieldKindMap | null,
51
60
  ): Promise<Record<string, unknown>> {
52
61
  if (!service || typeof service.decryptEntityPayload !== 'function') {
53
- return decryptIndexDocCustomFields(doc, scope, service, cache)
62
+ return decryptIndexDocCustomFields(doc, scope, service, cache, kinds)
54
63
  }
55
64
  if (service.isEnabled?.() === false) {
56
- return decryptIndexDocCustomFields(doc, scope, service, cache)
65
+ return decryptIndexDocCustomFields(doc, scope, service, cache, kinds)
57
66
  }
58
67
 
59
68
  let working: Record<string, unknown> = doc
@@ -72,7 +81,7 @@ export async function decryptIndexDocForSearch(
72
81
  await decryptEntity('customers:customer_entity')
73
82
  }
74
83
 
75
- return decryptIndexDocCustomFields(working, scope, service, cache)
84
+ return decryptIndexDocCustomFields(working, scope, service, cache, kinds)
76
85
  }
77
86
 
78
87
  export async function encryptIndexDocForStorage(
@@ -50,6 +50,55 @@ describe('dictionary fallback for locales the platform does not ship', () => {
50
50
  })
51
51
  })
52
52
 
53
+ describe('host app override precedence', () => {
54
+ it('lets the host app dictionary override a module-defined key', async () => {
55
+ registerModules([{ translations: { en: { greeting: 'Hello from module' } } }] as any)
56
+
57
+ const en = await loadDictionary('en')
58
+
59
+ // The host's own locale file must win — a module MUST NOT be able to
60
+ // silently shadow a key the host app explicitly set (issue #5995).
61
+ expect(en.greeting).toBe('Hello')
62
+ })
63
+
64
+ it('still falls back to a module key the host app does not define', async () => {
65
+ registerModules([{ translations: { en: { moduleOnly: 'Module value' } } }] as any)
66
+
67
+ const en = await loadDictionary('en')
68
+
69
+ expect(en.moduleOnly).toBe('Module value')
70
+ expect(en.greeting).toBe('Hello')
71
+ })
72
+
73
+ it('sees a module registered as a side effect of resolving the app loader on the first load of a locale', async () => {
74
+ // Mirrors apps/mercato/src/lib/i18n/register-dictionary-loader.ts: the
75
+ // registered loader lazily imports the locale's per-module translation
76
+ // bundle and calls registerModules() as a side effect, after an internal
77
+ // await. loadDictionary() MUST await loadAppDictionary() before reading
78
+ // tryGetModules() — reading the registry first would race the locale's
79
+ // first-ever load and permanently cache a dictionary missing this
80
+ // lazily-registered module's translations (regression fixed in
81
+ // 4f3a8e255).
82
+ registerAppDictionaryLoader(async (locale: Locale) => {
83
+ if (locale === 'pl') {
84
+ await Promise.resolve()
85
+ registerModules([
86
+ { translations: { pl: { greeting: 'Cześć (module)', fromModule: 'Z modułu' } } },
87
+ ] as any)
88
+ }
89
+ return APP_DICTIONARIES[locale] ?? {}
90
+ })
91
+
92
+ const pl = await loadDictionary('pl')
93
+
94
+ // The module key is present on the very first load, proving the module
95
+ // registered inside the loader was read after it resolved.
96
+ expect(pl.fromModule).toBe('Z modułu')
97
+ // The app dictionary still wins the collision (issue #5995).
98
+ expect(pl.greeting).toBe('Cześć')
99
+ })
100
+ })
101
+
53
102
  describe('an app-registered locale', () => {
54
103
  it('falls back to the default locale instead of rendering raw keys', async () => {
55
104
  registerLocales(['cs'])
@@ -94,18 +94,26 @@ export async function loadDictionary(locale: Locale): Promise<Dict> {
94
94
  const needsDefaultLocaleBase =
95
95
  locale !== defaultLocale && !(locales as readonly string[]).includes(locale)
96
96
  const merged: Dict = needsDefaultLocaleBase ? { ...(await loadDictionary(defaultLocale)) } : {}
97
- // Load from registry instead of @/ import (works in standalone packages)
97
+ // Load from registry instead of @/ import (works in standalone packages).
98
+ // Awaited BEFORE tryGetModules() below: the host app's dictionary loader
99
+ // (see apps/mercato/src/lib/i18n/register-dictionary-loader.ts) lazily
100
+ // imports this locale's per-module translation bundle and registers it via
101
+ // `registerModules()` as a side effect of resolving — reading the module
102
+ // registry first would race a locale's first-ever load and permanently
103
+ // cache a dictionary missing every lazily-registered module's translations
104
+ // for it (the registration event that would normally invalidate the cache
105
+ // fires too early, before this call has anything cached to invalidate).
98
106
  const baseRaw = await loadAppDictionary(locale)
99
- Object.assign(merged, flattenDictionary(baseRaw))
100
107
  // Route handlers translate their responses, so they resolve a dictionary even
101
- // when they are exercised in isolation without a bootstrapped registry. The
102
- // app dictionary alone is the right degraded answer there — `registerModules`
103
- // invalidates this cache, so a later bootstrap still gets the merged result.
108
+ // when they are exercised in isolation without a bootstrapped registry. Module
109
+ // dictionaries are layered first so the host app dictionary — merged last,
110
+ // below — is always the final word and can override any module-defined key.
104
111
  const modules = tryGetModules() ?? []
105
112
  for (const m of modules) {
106
113
  const dict = m.translations?.[locale]
107
114
  if (dict) Object.assign(merged, flattenDictionary(dict))
108
115
  }
116
+ Object.assign(merged, flattenDictionary(baseRaw))
109
117
  setCachedDictionary(locale, merged)
110
118
  return merged
111
119
  }
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
1
3
  import {
2
4
  COUNTRY_PRIORITY,
3
5
  ISO_COUNTRIES,
@@ -6,7 +8,30 @@ import {
6
8
  resolveCountryName,
7
9
  } from '../countries'
8
10
 
11
+ const PACKAGE_ROOT = join(__dirname, '..', '..', '..', '..')
12
+ const DIST_COUNTRIES = join(PACKAGE_ROOT, 'dist/lib/location/countries.js')
13
+ const DIST_GENERATED = join(PACKAGE_ROOT, 'dist/lib/location/countries.generated.js')
14
+
9
15
  describe('ISO_COUNTRIES', () => {
16
+ it('does not import language-subtag-registry at runtime (Node ESM / production)', () => {
17
+ const source = readFileSync(join(__dirname, '../countries.ts'), 'utf8')
18
+ expect(source).not.toMatch(/language-subtag-registry/)
19
+ const generated = readFileSync(join(__dirname, '../countries.generated.ts'), 'utf8')
20
+ expect(generated).toMatch(/AUTO-GENERATED/)
21
+ expect(generated).toMatch(/code: "PL"/)
22
+ expect(generated).toMatch(/code: "DE"/)
23
+
24
+ expect(existsSync(DIST_COUNTRIES)).toBe(true)
25
+ expect(existsSync(DIST_GENERATED)).toBe(true)
26
+ const distCountries = readFileSync(DIST_COUNTRIES, 'utf8')
27
+ const distGenerated = readFileSync(DIST_GENERATED, 'utf8')
28
+ expect(distCountries).not.toMatch(/language-subtag-registry/)
29
+ expect(distGenerated).not.toMatch(/language-subtag-registry/)
30
+ expect(distCountries).toMatch(/from "\.\/countries\.generated\.js"/)
31
+ expect(distGenerated).toMatch(/code: "PL"/)
32
+ expect(distGenerated).toMatch(/code: "DE"/)
33
+ })
34
+
10
35
  it('includes Kosovo, which the language-subtag registry does not list', () => {
11
36
  const kosovo = ISO_COUNTRIES.filter((entry) => entry.code === 'XK')
12
37
  expect(kosovo).toEqual([{ code: 'XK', name: 'Kosovo' }])
@@ -0,0 +1,264 @@
1
+ // AUTO-GENERATED by scripts/generate-countries.mjs — do not edit by hand.
2
+ // Regenerate via `node packages/shared/scripts/generate-countries.mjs` or the shared package build.
3
+
4
+ export const REGISTRY_COUNTRIES: Array<{ code: string; name: string }> = [
5
+ { code: "AC", name: "Ascension Island" },
6
+ { code: "AD", name: "Andorra" },
7
+ { code: "AE", name: "United Arab Emirates" },
8
+ { code: "AF", name: "Afghanistan" },
9
+ { code: "AG", name: "Antigua and Barbuda" },
10
+ { code: "AI", name: "Anguilla" },
11
+ { code: "AL", name: "Albania" },
12
+ { code: "AM", name: "Armenia" },
13
+ { code: "AO", name: "Angola" },
14
+ { code: "AQ", name: "Antarctica" },
15
+ { code: "AR", name: "Argentina" },
16
+ { code: "AS", name: "American Samoa" },
17
+ { code: "AT", name: "Austria" },
18
+ { code: "AU", name: "Australia" },
19
+ { code: "AW", name: "Aruba" },
20
+ { code: "AX", name: "Åland Islands" },
21
+ { code: "AZ", name: "Azerbaijan" },
22
+ { code: "BA", name: "Bosnia and Herzegovina" },
23
+ { code: "BB", name: "Barbados" },
24
+ { code: "BD", name: "Bangladesh" },
25
+ { code: "BE", name: "Belgium" },
26
+ { code: "BF", name: "Burkina Faso" },
27
+ { code: "BG", name: "Bulgaria" },
28
+ { code: "BH", name: "Bahrain" },
29
+ { code: "BI", name: "Burundi" },
30
+ { code: "BJ", name: "Benin" },
31
+ { code: "BL", name: "Saint Barthélemy" },
32
+ { code: "BM", name: "Bermuda" },
33
+ { code: "BN", name: "Brunei Darussalam" },
34
+ { code: "BO", name: "Bolivia" },
35
+ { code: "BQ", name: "Bonaire, Sint Eustatius and Saba" },
36
+ { code: "BR", name: "Brazil" },
37
+ { code: "BS", name: "Bahamas" },
38
+ { code: "BT", name: "Bhutan" },
39
+ { code: "BV", name: "Bouvet Island" },
40
+ { code: "BW", name: "Botswana" },
41
+ { code: "BY", name: "Belarus" },
42
+ { code: "BZ", name: "Belize" },
43
+ { code: "CA", name: "Canada" },
44
+ { code: "CC", name: "Cocos (Keeling) Islands" },
45
+ { code: "CD", name: "The Democratic Republic of the Congo" },
46
+ { code: "CF", name: "Central African Republic" },
47
+ { code: "CG", name: "Congo" },
48
+ { code: "CH", name: "Switzerland" },
49
+ { code: "CI", name: "Côte d'Ivoire" },
50
+ { code: "CK", name: "Cook Islands" },
51
+ { code: "CL", name: "Chile" },
52
+ { code: "CM", name: "Cameroon" },
53
+ { code: "CN", name: "China" },
54
+ { code: "CO", name: "Colombia" },
55
+ { code: "CP", name: "Clipperton Island" },
56
+ { code: "CQ", name: "Sark" },
57
+ { code: "CR", name: "Costa Rica" },
58
+ { code: "CU", name: "Cuba" },
59
+ { code: "CV", name: "Cabo Verde, Cape Verde" },
60
+ { code: "CW", name: "Curaçao" },
61
+ { code: "CX", name: "Christmas Island" },
62
+ { code: "CY", name: "Cyprus" },
63
+ { code: "CZ", name: "Czechia, Czech Republic" },
64
+ { code: "DE", name: "Germany" },
65
+ { code: "DG", name: "Diego Garcia" },
66
+ { code: "DJ", name: "Djibouti" },
67
+ { code: "DK", name: "Denmark" },
68
+ { code: "DM", name: "Dominica" },
69
+ { code: "DO", name: "Dominican Republic" },
70
+ { code: "DZ", name: "Algeria" },
71
+ { code: "EA", name: "Ceuta, Melilla" },
72
+ { code: "EC", name: "Ecuador" },
73
+ { code: "EE", name: "Estonia" },
74
+ { code: "EG", name: "Egypt" },
75
+ { code: "EH", name: "Western Sahara" },
76
+ { code: "ER", name: "Eritrea" },
77
+ { code: "ES", name: "Spain" },
78
+ { code: "ET", name: "Ethiopia" },
79
+ { code: "EU", name: "European Union" },
80
+ { code: "EZ", name: "Eurozone" },
81
+ { code: "FI", name: "Finland" },
82
+ { code: "FJ", name: "Fiji" },
83
+ { code: "FK", name: "Falkland Islands (Malvinas)" },
84
+ { code: "FM", name: "Federated States of Micronesia" },
85
+ { code: "FO", name: "Faroe Islands" },
86
+ { code: "FR", name: "France" },
87
+ { code: "GA", name: "Gabon" },
88
+ { code: "GB", name: "United Kingdom" },
89
+ { code: "GD", name: "Grenada" },
90
+ { code: "GE", name: "Georgia" },
91
+ { code: "GF", name: "French Guiana" },
92
+ { code: "GG", name: "Guernsey" },
93
+ { code: "GH", name: "Ghana" },
94
+ { code: "GI", name: "Gibraltar" },
95
+ { code: "GL", name: "Greenland" },
96
+ { code: "GM", name: "Gambia" },
97
+ { code: "GN", name: "Guinea" },
98
+ { code: "GP", name: "Guadeloupe" },
99
+ { code: "GQ", name: "Equatorial Guinea" },
100
+ { code: "GR", name: "Greece" },
101
+ { code: "GS", name: "South Georgia and the South Sandwich Islands" },
102
+ { code: "GT", name: "Guatemala" },
103
+ { code: "GU", name: "Guam" },
104
+ { code: "GW", name: "Guinea-Bissau" },
105
+ { code: "GY", name: "Guyana" },
106
+ { code: "HK", name: "Hong Kong" },
107
+ { code: "HM", name: "Heard Island and McDonald Islands" },
108
+ { code: "HN", name: "Honduras" },
109
+ { code: "HR", name: "Croatia" },
110
+ { code: "HT", name: "Haiti" },
111
+ { code: "HU", name: "Hungary" },
112
+ { code: "IC", name: "Canary Islands" },
113
+ { code: "ID", name: "Indonesia" },
114
+ { code: "IE", name: "Ireland" },
115
+ { code: "IL", name: "Israel" },
116
+ { code: "IM", name: "Isle of Man" },
117
+ { code: "IN", name: "India" },
118
+ { code: "IO", name: "British Indian Ocean Territory" },
119
+ { code: "IQ", name: "Iraq" },
120
+ { code: "IR", name: "Islamic Republic of Iran" },
121
+ { code: "IS", name: "Iceland" },
122
+ { code: "IT", name: "Italy" },
123
+ { code: "JE", name: "Jersey" },
124
+ { code: "JM", name: "Jamaica" },
125
+ { code: "JO", name: "Jordan" },
126
+ { code: "JP", name: "Japan" },
127
+ { code: "KE", name: "Kenya" },
128
+ { code: "KG", name: "Kyrgyzstan" },
129
+ { code: "KH", name: "Cambodia" },
130
+ { code: "KI", name: "Kiribati" },
131
+ { code: "KM", name: "Comoros" },
132
+ { code: "KN", name: "Saint Kitts and Nevis" },
133
+ { code: "KP", name: "Democratic People's Republic of Korea" },
134
+ { code: "KR", name: "Republic of Korea" },
135
+ { code: "KW", name: "Kuwait" },
136
+ { code: "KY", name: "Cayman Islands" },
137
+ { code: "KZ", name: "Kazakhstan" },
138
+ { code: "LA", name: "Lao People's Democratic Republic" },
139
+ { code: "LB", name: "Lebanon" },
140
+ { code: "LC", name: "Saint Lucia" },
141
+ { code: "LI", name: "Liechtenstein" },
142
+ { code: "LK", name: "Sri Lanka" },
143
+ { code: "LR", name: "Liberia" },
144
+ { code: "LS", name: "Lesotho" },
145
+ { code: "LT", name: "Lithuania" },
146
+ { code: "LU", name: "Luxembourg" },
147
+ { code: "LV", name: "Latvia" },
148
+ { code: "LY", name: "Libya" },
149
+ { code: "MA", name: "Morocco" },
150
+ { code: "MC", name: "Monaco" },
151
+ { code: "MD", name: "Moldova" },
152
+ { code: "ME", name: "Montenegro" },
153
+ { code: "MF", name: "Saint Martin (French part)" },
154
+ { code: "MG", name: "Madagascar" },
155
+ { code: "MH", name: "Marshall Islands" },
156
+ { code: "MK", name: "North Macedonia" },
157
+ { code: "ML", name: "Mali" },
158
+ { code: "MM", name: "Myanmar" },
159
+ { code: "MN", name: "Mongolia" },
160
+ { code: "MO", name: "Macao" },
161
+ { code: "MP", name: "Northern Mariana Islands" },
162
+ { code: "MQ", name: "Martinique" },
163
+ { code: "MR", name: "Mauritania" },
164
+ { code: "MS", name: "Montserrat" },
165
+ { code: "MT", name: "Malta" },
166
+ { code: "MU", name: "Mauritius" },
167
+ { code: "MV", name: "Maldives" },
168
+ { code: "MW", name: "Malawi" },
169
+ { code: "MX", name: "Mexico" },
170
+ { code: "MY", name: "Malaysia" },
171
+ { code: "MZ", name: "Mozambique" },
172
+ { code: "NA", name: "Namibia" },
173
+ { code: "NC", name: "New Caledonia" },
174
+ { code: "NE", name: "Niger" },
175
+ { code: "NF", name: "Norfolk Island" },
176
+ { code: "NG", name: "Nigeria" },
177
+ { code: "NI", name: "Nicaragua" },
178
+ { code: "NL", name: "Netherlands" },
179
+ { code: "NO", name: "Norway" },
180
+ { code: "NP", name: "Nepal" },
181
+ { code: "NR", name: "Nauru" },
182
+ { code: "NU", name: "Niue" },
183
+ { code: "NZ", name: "New Zealand" },
184
+ { code: "OM", name: "Oman" },
185
+ { code: "PA", name: "Panama" },
186
+ { code: "PE", name: "Peru" },
187
+ { code: "PF", name: "French Polynesia" },
188
+ { code: "PG", name: "Papua New Guinea" },
189
+ { code: "PH", name: "Philippines" },
190
+ { code: "PK", name: "Pakistan" },
191
+ { code: "PL", name: "Poland" },
192
+ { code: "PM", name: "Saint Pierre and Miquelon" },
193
+ { code: "PN", name: "Pitcairn" },
194
+ { code: "PR", name: "Puerto Rico" },
195
+ { code: "PS", name: "State of Palestine" },
196
+ { code: "PT", name: "Portugal" },
197
+ { code: "PW", name: "Palau" },
198
+ { code: "PY", name: "Paraguay" },
199
+ { code: "QA", name: "Qatar" },
200
+ { code: "RE", name: "Réunion" },
201
+ { code: "RO", name: "Romania" },
202
+ { code: "RS", name: "Serbia" },
203
+ { code: "RU", name: "Russian Federation" },
204
+ { code: "RW", name: "Rwanda" },
205
+ { code: "SA", name: "Saudi Arabia" },
206
+ { code: "SB", name: "Solomon Islands" },
207
+ { code: "SC", name: "Seychelles" },
208
+ { code: "SD", name: "Sudan" },
209
+ { code: "SE", name: "Sweden" },
210
+ { code: "SG", name: "Singapore" },
211
+ { code: "SH", name: "Saint Helena, Ascension and Tristan da Cunha" },
212
+ { code: "SI", name: "Slovenia" },
213
+ { code: "SJ", name: "Svalbard and Jan Mayen" },
214
+ { code: "SK", name: "Slovakia" },
215
+ { code: "SL", name: "Sierra Leone" },
216
+ { code: "SM", name: "San Marino" },
217
+ { code: "SN", name: "Senegal" },
218
+ { code: "SO", name: "Somalia" },
219
+ { code: "SR", name: "Suriname" },
220
+ { code: "SS", name: "South Sudan" },
221
+ { code: "ST", name: "Sao Tome and Principe" },
222
+ { code: "SV", name: "El Salvador" },
223
+ { code: "SX", name: "Sint Maarten (Dutch part)" },
224
+ { code: "SY", name: "Syrian Arab Republic" },
225
+ { code: "SZ", name: "Eswatini, eSwatini, Swaziland" },
226
+ { code: "TA", name: "Tristan da Cunha" },
227
+ { code: "TC", name: "Turks and Caicos Islands" },
228
+ { code: "TD", name: "Chad" },
229
+ { code: "TF", name: "French Southern Territories" },
230
+ { code: "TG", name: "Togo" },
231
+ { code: "TH", name: "Thailand" },
232
+ { code: "TJ", name: "Tajikistan" },
233
+ { code: "TK", name: "Tokelau" },
234
+ { code: "TL", name: "Timor-Leste" },
235
+ { code: "TM", name: "Turkmenistan" },
236
+ { code: "TN", name: "Tunisia" },
237
+ { code: "TO", name: "Tonga" },
238
+ { code: "TR", name: "Türkiye, Turkey" },
239
+ { code: "TT", name: "Trinidad and Tobago" },
240
+ { code: "TV", name: "Tuvalu" },
241
+ { code: "TW", name: "Taiwan, Province of China" },
242
+ { code: "TZ", name: "United Republic of Tanzania" },
243
+ { code: "UA", name: "Ukraine" },
244
+ { code: "UG", name: "Uganda" },
245
+ { code: "UM", name: "United States Minor Outlying Islands" },
246
+ { code: "UN", name: "United Nations" },
247
+ { code: "US", name: "United States" },
248
+ { code: "UY", name: "Uruguay" },
249
+ { code: "UZ", name: "Uzbekistan" },
250
+ { code: "VA", name: "Holy See (Vatican City State)" },
251
+ { code: "VC", name: "Saint Vincent and the Grenadines" },
252
+ { code: "VE", name: "Venezuela" },
253
+ { code: "VG", name: "British Virgin Islands" },
254
+ { code: "VI", name: "U.S. Virgin Islands" },
255
+ { code: "VN", name: "Viet Nam" },
256
+ { code: "VU", name: "Vanuatu" },
257
+ { code: "WF", name: "Wallis and Futuna" },
258
+ { code: "WS", name: "Samoa" },
259
+ { code: "YE", name: "Yemen" },
260
+ { code: "YT", name: "Mayotte" },
261
+ { code: "ZA", name: "South Africa" },
262
+ { code: "ZM", name: "Zambia" },
263
+ { code: "ZW", name: "Zimbabwe" },
264
+ ]
@@ -1,32 +1,11 @@
1
- import registry from 'language-subtag-registry/data/json/registry.json'
2
-
3
- type RegistryEntry = {
4
- Type: string
5
- Subtag?: string
6
- Description?: string[]
7
- Deprecated?: string
8
- }
1
+ import { REGISTRY_COUNTRIES } from './countries.generated'
9
2
 
10
3
  export type IsoCountry = {
11
4
  code: string
12
5
  name: string
13
6
  }
14
7
 
15
- function isIsoAlpha2(entry: RegistryEntry): entry is RegistryEntry & { Subtag: string; Description: string[] } {
16
- if (entry.Type !== 'region') return false
17
- if (!entry.Subtag || !/^[A-Z]{2}$/.test(entry.Subtag)) return false
18
- if (entry.Deprecated) return false
19
- if (!entry.Description || !entry.Description.length) return false
20
- if (entry.Description[0] === 'Private use') return false
21
- return true
22
- }
23
-
24
- const RAW_COUNTRIES: IsoCountry[] = (registry as RegistryEntry[])
25
- .filter(isIsoAlpha2)
26
- .map((entry) => ({
27
- code: entry.Subtag,
28
- name: entry.Description.join(', '),
29
- }))
8
+ const RAW_COUNTRIES: IsoCountry[] = REGISTRY_COUNTRIES
30
9
 
31
10
  // Codes in common use that the language-subtag registry does not list as region subtags.
32
11
  // XK is user-assigned rather than ISO 3166-1 assigned, so it never appears in the registry.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * These helpers exist so a boundary-crossing portal navigation re-runs the server layout that
3
+ * computes the portal chrome and the session. Which document API each one uses is the whole
4
+ * contract: `assign` keeps the current page in session history, `replace` does not, so they are
5
+ * the document-loading counterparts of `router.push` and `router.replace` respectively. Swapping
6
+ * one for the other is invisible on screen and only surfaces as a broken Back button, so pin both.
7
+ *
8
+ * This runs in the node environment on purpose: jsdom exposes `window.location` as a
9
+ * non-configurable getter whose `assign`/`replace` are non-writable, so the real navigation calls
10
+ * cannot be observed there. A synthetic `window` lets the helper bodies run for real.
11
+ */
12
+
13
+ const assign = jest.fn()
14
+ const replace = jest.fn()
15
+
16
+ describe('page reload navigation helpers', () => {
17
+ let navigateWithPageReload: (path: string) => void
18
+ let replaceWithPageReload: (path: string) => void
19
+
20
+ beforeAll(() => {
21
+ Object.defineProperty(globalThis, 'window', {
22
+ configurable: true,
23
+ writable: true,
24
+ value: { location: { assign, replace } },
25
+ })
26
+ // Imported after the synthetic window exists, so the module resolves against it.
27
+ const helpers = require('../pageReload')
28
+ navigateWithPageReload = helpers.navigateWithPageReload
29
+ replaceWithPageReload = helpers.replaceWithPageReload
30
+ })
31
+
32
+ afterAll(() => {
33
+ delete (globalThis as { window?: unknown }).window
34
+ })
35
+
36
+ beforeEach(() => {
37
+ assign.mockReset()
38
+ replace.mockReset()
39
+ })
40
+
41
+ describe('navigateWithPageReload', () => {
42
+ it('loads the path and keeps the current page in session history', () => {
43
+ navigateWithPageReload('/acme/portal/dashboard')
44
+
45
+ expect(assign).toHaveBeenCalledWith('/acme/portal/dashboard')
46
+ expect(replace).not.toHaveBeenCalled()
47
+ })
48
+ })
49
+
50
+ describe('replaceWithPageReload', () => {
51
+ it('loads the path without leaving the redirecting page in session history', () => {
52
+ replaceWithPageReload('/acme/portal/login')
53
+
54
+ expect(replace).toHaveBeenCalledWith('/acme/portal/login')
55
+ expect(assign).not.toHaveBeenCalled()
56
+ })
57
+ })
58
+ })
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Boundary-crossing navigation helpers: they reload the document instead of handing the route to
3
+ * the client router.
4
+ *
5
+ * The portal's `(frontend)` layout — the server component that decides the portal chrome and
6
+ * resolves the customer session — sits *above* the `[...slug]` segment so portal navigation does
7
+ * not remount the client subtree. A client-side `router.push`/`router.replace` therefore never
8
+ * re-runs that layout, and any navigation that crosses the public/authenticated boundary would
9
+ * keep rendering chrome computed for the page it came from: authenticated chrome over the login
10
+ * page after a logout, or logged-out chrome over the dashboard after a login.
11
+ *
12
+ * Use these helpers for every boundary-crossing navigation (login, logout, signup completion,
13
+ * invite acceptance, auth guards redirecting to login) so the layout re-runs and recomputes both
14
+ * the chrome and the session. Same-side navigation should keep using the client router.
15
+ *
16
+ * Pick the one that matches the client-router call it stands in for, so session history behaves
17
+ * the same way: `navigateWithPageReload` for `router.push`, `replaceWithPageReload` for
18
+ * `router.replace`.
19
+ */
20
+
21
+ /** Full page load that adds a session-history entry — the document-loading counterpart of `router.push`. */
22
+ export function navigateWithPageReload(path: string): void {
23
+ window.location.assign(path)
24
+ }
25
+
26
+ /**
27
+ * Full page load that replaces the current session-history entry — the document-loading
28
+ * counterpart of `router.replace`.
29
+ *
30
+ * Use this where the client-router call being replaced was a `router.replace`: an auth guard
31
+ * sending an unauthenticated visitor to the login page, or the portal landing page forwarding a
32
+ * signed-in customer to the dashboard.
33
+ *
34
+ * Note that for those call sites specifically, `navigateWithPageReload` was measured to behave
35
+ * identically: they redirect from an effect during page load, and a browser treats a navigation
36
+ * started then as a client redirect, replacing the entry rather than pushing it whichever method
37
+ * is used. This helper exists so the call site states which semantics it means instead of relying
38
+ * on that heuristic — and so a redirect that later moves behind a user gesture, where the
39
+ * distinction does bite, keeps the behaviour it was written with.
40
+ */
41
+ export function replaceWithPageReload(path: string): void {
42
+ window.location.replace(path)
43
+ }