@fgv/ts-res 5.0.0-22 → 5.0.0-23

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 (189) hide show
  1. package/README.md +665 -44
  2. package/dist/ts-res.d.ts +421 -19
  3. package/lib/packlets/common/convert.d.ts +10 -0
  4. package/lib/packlets/common/convert.js +11 -1
  5. package/lib/packlets/common/resources.d.ts +14 -0
  6. package/lib/packlets/common/validate/regularExpressions.d.ts +4 -0
  7. package/lib/packlets/common/validate/regularExpressions.js +5 -1
  8. package/lib/packlets/common/validate/resources.d.ts +35 -1
  9. package/lib/packlets/common/validate/resources.js +54 -0
  10. package/lib/packlets/config/convert.js +1 -1
  11. package/lib/packlets/config/json.d.ts +1 -1
  12. package/lib/packlets/qualifier-types/config/convert.d.ts +22 -1
  13. package/lib/packlets/qualifier-types/config/convert.js +67 -2
  14. package/lib/packlets/qualifier-types/config/json.d.ts +11 -2
  15. package/lib/packlets/qualifier-types/helpers.d.ts +1 -2
  16. package/lib/packlets/qualifier-types/languageQualifierType.d.ts +27 -1
  17. package/lib/packlets/qualifier-types/languageQualifierType.js +75 -0
  18. package/lib/packlets/qualifier-types/literalQualifierType.d.ts +30 -4
  19. package/lib/packlets/qualifier-types/literalQualifierType.js +77 -0
  20. package/lib/packlets/qualifier-types/literalValueHierarchy.d.ts +6 -10
  21. package/lib/packlets/qualifier-types/literalValueHierarchy.js +10 -0
  22. package/lib/packlets/qualifier-types/qualifierType.d.ts +28 -0
  23. package/lib/packlets/qualifier-types/territoryQualifierType.d.ts +28 -4
  24. package/lib/packlets/qualifier-types/territoryQualifierType.js +77 -0
  25. package/lib/packlets/resource-json/compiled/convert.js +2 -1
  26. package/lib/packlets/resource-json/compiled/json.d.ts +6 -2
  27. package/lib/packlets/resource-types/jsonResourceType.d.ts +4 -0
  28. package/lib/packlets/resource-types/jsonResourceType.js +4 -0
  29. package/lib/packlets/resource-types/resourceType.d.ts +4 -0
  30. package/lib/packlets/resources/candidateValue.d.ts +92 -0
  31. package/lib/packlets/resources/candidateValue.js +128 -0
  32. package/lib/packlets/resources/candidateValueCollector.d.ts +55 -0
  33. package/lib/packlets/resources/candidateValueCollector.js +96 -0
  34. package/lib/packlets/resources/index.d.ts +2 -0
  35. package/lib/packlets/resources/index.js +2 -0
  36. package/lib/packlets/resources/resource.js +2 -2
  37. package/lib/packlets/resources/resourceBuilder.d.ts +6 -0
  38. package/lib/packlets/resources/resourceBuilder.js +3 -1
  39. package/lib/packlets/resources/resourceCandidate.d.ts +10 -1
  40. package/lib/packlets/resources/resourceCandidate.js +11 -5
  41. package/lib/packlets/resources/resourceManagerBuilder.d.ts +7 -0
  42. package/lib/packlets/resources/resourceManagerBuilder.js +13 -6
  43. package/lib/packlets/runtime/compiledResourceCollection.d.ts +20 -8
  44. package/lib/packlets/runtime/compiledResourceCollection.js +25 -4
  45. package/lib/packlets/runtime/resource-tree/common.d.ts +1 -1
  46. package/lib/test/unit/qualifier-types/testQualifierType.d.ts +4 -0
  47. package/lib/test/unit/qualifier-types/testQualifierType.js +24 -0
  48. package/lib/test/unit/resource-types/testDerivedResourceType.d.ts +1 -0
  49. package/lib/test/unit/resource-types/testDerivedResourceType.js +3 -0
  50. package/lib/test/unit/resources/candidateValue.test.d.ts +2 -0
  51. package/lib/test/unit/resources/candidateValueCollector.test.d.ts +2 -0
  52. package/package.json +7 -7
  53. package/src/index.ts +0 -75
  54. package/src/packlets/bundle/bundleBuilder.ts +0 -159
  55. package/src/packlets/bundle/bundleLoader.ts +0 -157
  56. package/src/packlets/bundle/bundleNormalizer.ts +0 -173
  57. package/src/packlets/bundle/bundleUtils.ts +0 -142
  58. package/src/packlets/bundle/convert.ts +0 -87
  59. package/src/packlets/bundle/index.ts +0 -52
  60. package/src/packlets/bundle/model.ts +0 -138
  61. package/src/packlets/common/conditions.ts +0 -226
  62. package/src/packlets/common/convert.ts +0 -184
  63. package/src/packlets/common/helpers/conditions.ts +0 -84
  64. package/src/packlets/common/helpers/context.ts +0 -88
  65. package/src/packlets/common/helpers/index.ts +0 -26
  66. package/src/packlets/common/helpers/qualifierDefaultValues.ts +0 -91
  67. package/src/packlets/common/helpers/resources.ts +0 -96
  68. package/src/packlets/common/index.ts +0 -30
  69. package/src/packlets/common/resources.ts +0 -105
  70. package/src/packlets/common/validate/conditions.ts +0 -595
  71. package/src/packlets/common/validate/index.ts +0 -27
  72. package/src/packlets/common/validate/regularExpressions.ts +0 -92
  73. package/src/packlets/common/validate/resources.ts +0 -170
  74. package/src/packlets/conditions/condition.ts +0 -351
  75. package/src/packlets/conditions/conditionCollector.ts +0 -103
  76. package/src/packlets/conditions/conditionDecls.ts +0 -50
  77. package/src/packlets/conditions/conditionSet.ts +0 -318
  78. package/src/packlets/conditions/conditionSetCollector.ts +0 -127
  79. package/src/packlets/conditions/conditionSetDecls.ts +0 -41
  80. package/src/packlets/conditions/conditionToken.ts +0 -180
  81. package/src/packlets/conditions/convert/conditionSetDecls.ts +0 -72
  82. package/src/packlets/conditions/convert/decls.ts +0 -95
  83. package/src/packlets/conditions/convert/index.ts +0 -24
  84. package/src/packlets/conditions/index.ts +0 -33
  85. package/src/packlets/config/common.ts +0 -107
  86. package/src/packlets/config/configInitFactory.ts +0 -142
  87. package/src/packlets/config/convert.ts +0 -61
  88. package/src/packlets/config/index.ts +0 -30
  89. package/src/packlets/config/json.ts +0 -43
  90. package/src/packlets/config/predefined/default.ts +0 -150
  91. package/src/packlets/config/predefined/extended.ts +0 -198
  92. package/src/packlets/config/predefined/index.ts +0 -26
  93. package/src/packlets/config/systemConfiguration.ts +0 -217
  94. package/src/packlets/context/contextDecls.ts +0 -76
  95. package/src/packlets/context/contextToken.ts +0 -252
  96. package/src/packlets/context/convert/decls.ts +0 -126
  97. package/src/packlets/context/convert/index.ts +0 -23
  98. package/src/packlets/context/index.ts +0 -27
  99. package/src/packlets/decisions/abstractDecision.ts +0 -87
  100. package/src/packlets/decisions/abstractDecisionCollector.ts +0 -122
  101. package/src/packlets/decisions/candidate.ts +0 -101
  102. package/src/packlets/decisions/common.ts +0 -37
  103. package/src/packlets/decisions/concreteDecision.ts +0 -157
  104. package/src/packlets/decisions/decision.ts +0 -172
  105. package/src/packlets/decisions/index.ts +0 -28
  106. package/src/packlets/import/fsItem.ts +0 -212
  107. package/src/packlets/import/importContext.ts +0 -183
  108. package/src/packlets/import/importManager.ts +0 -187
  109. package/src/packlets/import/importable.ts +0 -107
  110. package/src/packlets/import/importers/collectionImporter.ts +0 -153
  111. package/src/packlets/import/importers/fsItemImporter.ts +0 -154
  112. package/src/packlets/import/importers/importer.ts +0 -58
  113. package/src/packlets/import/importers/index.ts +0 -27
  114. package/src/packlets/import/importers/jsonImporter.ts +0 -133
  115. package/src/packlets/import/importers/pathImporter.ts +0 -134
  116. package/src/packlets/import/index.ts +0 -29
  117. package/src/packlets/qualifier-types/config/convert.ts +0 -108
  118. package/src/packlets/qualifier-types/config/index.ts +0 -27
  119. package/src/packlets/qualifier-types/config/json.ts +0 -126
  120. package/src/packlets/qualifier-types/convert.ts +0 -77
  121. package/src/packlets/qualifier-types/helpers.ts +0 -94
  122. package/src/packlets/qualifier-types/index.ts +0 -34
  123. package/src/packlets/qualifier-types/languageQualifierType.ts +0 -143
  124. package/src/packlets/qualifier-types/literalQualifierType.ts +0 -244
  125. package/src/packlets/qualifier-types/literalValueHierarchy.ts +0 -332
  126. package/src/packlets/qualifier-types/qualifierType.ts +0 -360
  127. package/src/packlets/qualifier-types/qualifierTypeCollector.ts +0 -102
  128. package/src/packlets/qualifier-types/territoryQualifierType.ts +0 -255
  129. package/src/packlets/qualifiers/convert/decls.ts +0 -90
  130. package/src/packlets/qualifiers/convert/index.ts +0 -24
  131. package/src/packlets/qualifiers/convert/qualifier.ts +0 -55
  132. package/src/packlets/qualifiers/index.ts +0 -31
  133. package/src/packlets/qualifiers/qualifier.ts +0 -169
  134. package/src/packlets/qualifiers/qualifierCollector.ts +0 -194
  135. package/src/packlets/qualifiers/qualifierDecl.ts +0 -80
  136. package/src/packlets/qualifiers/qualifierDefaultValueDecls.ts +0 -57
  137. package/src/packlets/qualifiers/qualifierDefaultValueToken.ts +0 -230
  138. package/src/packlets/resource-json/compiled/common.ts +0 -35
  139. package/src/packlets/resource-json/compiled/convert.ts +0 -143
  140. package/src/packlets/resource-json/compiled/index.ts +0 -27
  141. package/src/packlets/resource-json/compiled/json.ts +0 -239
  142. package/src/packlets/resource-json/convert.ts +0 -236
  143. package/src/packlets/resource-json/helpers.ts +0 -284
  144. package/src/packlets/resource-json/index.ts +0 -33
  145. package/src/packlets/resource-json/json.ts +0 -295
  146. package/src/packlets/resource-json/normalized.ts +0 -211
  147. package/src/packlets/resource-json/resourceDeclCollection.ts +0 -117
  148. package/src/packlets/resource-json/resourceDeclContainer.ts +0 -48
  149. package/src/packlets/resource-json/resourceDeclTree.ts +0 -116
  150. package/src/packlets/resource-types/config/convert.ts +0 -38
  151. package/src/packlets/resource-types/config/index.ts +0 -27
  152. package/src/packlets/resource-types/config/json.ts +0 -33
  153. package/src/packlets/resource-types/helpers.ts +0 -44
  154. package/src/packlets/resource-types/index.ts +0 -30
  155. package/src/packlets/resource-types/jsonResourceType.ts +0 -102
  156. package/src/packlets/resource-types/resourceType.ts +0 -292
  157. package/src/packlets/resource-types/resourceTypeCollector.ts +0 -79
  158. package/src/packlets/resources/candidateReducer.ts +0 -331
  159. package/src/packlets/resources/common.ts +0 -114
  160. package/src/packlets/resources/index.ts +0 -28
  161. package/src/packlets/resources/resource.ts +0 -307
  162. package/src/packlets/resources/resourceBuilder.ts +0 -269
  163. package/src/packlets/resources/resourceCandidate.ts +0 -333
  164. package/src/packlets/resources/resourceManagerBuilder.ts +0 -1169
  165. package/src/packlets/runtime/cacheListener.ts +0 -113
  166. package/src/packlets/runtime/cacheMetrics.ts +0 -206
  167. package/src/packlets/runtime/compiledResourceCollection.ts +0 -607
  168. package/src/packlets/runtime/conditionSetResolutionResult.ts +0 -160
  169. package/src/packlets/runtime/context/contextQualifierProvider.ts +0 -117
  170. package/src/packlets/runtime/context/contextQualifierProviderValidator.ts +0 -281
  171. package/src/packlets/runtime/context/index.ts +0 -26
  172. package/src/packlets/runtime/context/simpleContextQualifierProvider.ts +0 -225
  173. package/src/packlets/runtime/context/validatingSimpleContextQualifierProvider.ts +0 -96
  174. package/src/packlets/runtime/iResourceManager.ts +0 -130
  175. package/src/packlets/runtime/index.ts +0 -35
  176. package/src/packlets/runtime/resource-tree/common.ts +0 -126
  177. package/src/packlets/runtime/resource-tree/index.ts +0 -26
  178. package/src/packlets/runtime/resource-tree/readOnlyResourceTree.ts +0 -342
  179. package/src/packlets/runtime/resource-tree/resourceTreeChildren.ts +0 -125
  180. package/src/packlets/runtime/resource-tree/resourceTreeChildrenValidator.ts +0 -187
  181. package/src/packlets/runtime/resourceResolver.ts +0 -712
  182. package/src/packlets/runtime/validate.ts +0 -63
  183. package/src/packlets/zip-archive/convert.ts +0 -121
  184. package/src/packlets/zip-archive/index.ts +0 -76
  185. package/src/packlets/zip-archive/json.ts +0 -91
  186. package/src/packlets/zip-archive/types.ts +0 -140
  187. package/src/packlets/zip-archive/zipArchiveCreator.ts +0 -229
  188. package/src/packlets/zip-archive/zipArchiveFormat.ts +0 -158
  189. package/src/packlets/zip-archive/zipArchiveLoader.ts +0 -370
package/README.md CHANGED
@@ -5,18 +5,123 @@
5
5
 
6
6
  A TypeScript library for multidimensional resource management with internationalization and localization support. `@fgv/ts-res` provides a sophisticated system for managing resources with complex conditional logic based on qualifiers like language, territory, and custom dimensions.
7
7
 
8
- ## Features
8
+ ## Understanding the Resource Model
9
9
 
10
- - **Multidimensional Resource Management**: Organize resources by language, territory, and custom qualifiers
11
- - **Conditional Resource Resolution**: Automatic selection of the most appropriate resource based on context
12
- - **Type-Safe**: Full TypeScript support with comprehensive type definitions
13
- - **Flexible Resource Formats**: Support for JSON resources with extensible resource types
14
- - **Hierarchical Organization**: Tree-based and collection-based resource structures
15
- - **File System Integration**: Import resources from file systems with automatic condition inference
16
- - **Priority-Based Resolution**: Configurable priority levels for different qualifiers
17
- - **Resource Merging**: Support for augmenting and replacing resources
18
- - **BCP47 Language Support**: Built-in support for BCP47 language tags
19
- - **Extensible Architecture**: Plugin-based system for custom qualifier types
10
+ ### What is a Resource?
11
+
12
+ In `@fgv/ts-res`, a **resource** represents a logical entity (like a user profile form, API configuration, or UI component) that can vary based on context. Instead of simple key-value pairs, resources are composed of **candidates** - each providing a full or partial representation of that entity for various different contexts.
13
+
14
+ The context is represented as a configurable set of typed **qualifiers** with some value. Each candidate has an associated **condition set** consisting of zero or more **conditions**, each of which specifies some qualifier and a value to be matched. Qualifiers not present in a condition set are irrelevant, so the empty condition set is *unconditional* and matches any context.
15
+
16
+ ```typescript
17
+ // Traditional i18n approach - separate keys, full duplication
18
+ {
19
+ "profile.title.en": "User Profile",
20
+ "profile.title.fr": "Profil Utilisateur",
21
+ "profile.button.en": "Save Changes",
22
+ "profile.button.fr": "Enregistrer les Modifications",
23
+ "profile.title.en-CA": "User Profile", // Duplicated!
24
+ "profile.button.en-CA": "Save Changes" // Duplicated!
25
+ }
26
+
27
+ // ts-res approach - one resource, multiple candidates
28
+ {
29
+ "id": "user.profile",
30
+ "candidates": [
31
+ {
32
+ "json": { "title": "User Profile", "button": "Save Changes" },
33
+ "conditions": { "language": "en" }
34
+ },
35
+ {
36
+ "json": { "title": "Profil Utilisateur", "button": "Enregistrer" },
37
+ "conditions": { "language": "fr" }
38
+ },
39
+ {
40
+ "json": { "button": "Save Eh!" }, // Only override button text
41
+ "conditions": { "language": "en", "territory": "CA" }
42
+ }
43
+ ]
44
+ }
45
+ ```
46
+
47
+ ### Partial Candidates: The Power of Composition
48
+
49
+ It is possible that several candidates for some resource will match any given context, which enables **composition** of **partial candidates** - you don't need to duplicate entire resources, just specify what changes:
50
+
51
+ ```typescript
52
+ // Base configuration for all environments
53
+ {
54
+ "id": "api.config",
55
+ "candidates": [
56
+ {
57
+ "json": {
58
+ "timeout": 5000,
59
+ "retries": 3,
60
+ "features": { "analytics": true, "debugging": false }
61
+ },
62
+ "conditions": {} // Base case - applies everywhere
63
+ },
64
+ {
65
+ "json": {
66
+ "url": "https://api.production.com",
67
+ "features": { "debugging": false }
68
+ },
69
+ "conditions": { "environment": "production" }
70
+ },
71
+ {
72
+ "json": {
73
+ "url": "https://api.dev.com",
74
+ "features": { "debugging": true } // Only override debugging
75
+ },
76
+ "conditions": { "environment": "development" }
77
+ }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ ### Three Resolution Strategies
83
+
84
+ **1. Single Best Match** - Pick the most specific candidate only:
85
+ ```typescript
86
+ // Context: { environment: "development" }
87
+ const bestMatch = resolver.resolveBestResourceCandidate('api.config').orThrow();
88
+ console.log(bestMatch.candidate.json);
89
+ // Returns: { "url": "api.dev.com", "features": { "debugging": true } }
90
+ // Note: Only the development candidate, not merged with base
91
+ ```
92
+
93
+ **2. All Matching Candidates** - Get everything that matches for analysis:
94
+ ```typescript
95
+ const allMatches = resolver.resolveAllCandidates('api.config').orThrow();
96
+ // Returns array: [base candidate, development candidate]
97
+ // Each candidate as separate objects, not merged
98
+ ```
99
+
100
+ **3. Composed Resolution** - Merge all matching candidates in priority order:
101
+ ```typescript
102
+ // Context: { language: "en", territory: "CA", userType: "admin" }
103
+ const composed = resolver.resolveComposedResourceValue('user.dashboard').orThrow();
104
+ console.log(composed);
105
+ // Returns merged result: {
106
+ // title: 'Dashboard', // from base English
107
+ // nav: { home: 'Home', settings: 'Preferences' }, // base + Canadian override
108
+ // actions: {
109
+ // save: 'Save, eh!', // from Canadian variant
110
+ // cancel: 'Cancel', // from base English
111
+ // admin: 'Admin Panel' // from admin variant
112
+ // }
113
+ // }
114
+ ```
115
+
116
+ ### Why This Matters
117
+
118
+ **Eliminates Duplication**: Change base configuration once, variants inherit automatically
119
+
120
+ **Handles Complexity**: Easily manage overlapping conditions (Canadian English admin users vs regular Canadian English users)
121
+
122
+ **Maintainable**: Add new dimensions (user types, themes, devices) without restructuring existing resources
123
+
124
+ **Flexible**: Same resource system handles i18n, configuration management, feature flags, and more
20
125
 
21
126
  ## Installation
22
127
 
@@ -26,31 +131,46 @@ npm install @fgv/ts-res
26
131
 
27
132
  ## Quick Start
28
133
 
134
+ This example demonstrates all three resolution strategies using a user dashboard that varies by language, region, and user type:
135
+
29
136
  ```typescript
30
137
  import * as TsRes from '@fgv/ts-res';
31
138
 
32
139
  // 1. Create a resource manager using predefined configuration
33
140
  const manager = TsRes.Resources.ResourceManagerBuilder.createPredefined('default').orThrow();
34
141
 
35
- // 2. Add resources with different language/territory conditions
142
+ // 2. Add a resource with base + partial candidates
36
143
  manager.addResource({
37
- id: 'greeting.message',
144
+ id: 'user.dashboard',
38
145
  resourceTypeName: 'json',
39
146
  candidates: [
147
+ // Base English dashboard
40
148
  {
41
- json: { message: 'Hello' },
149
+ json: {
150
+ title: 'Dashboard',
151
+ nav: { home: 'Home', settings: 'Settings' },
152
+ actions: { save: 'Save', cancel: 'Cancel' }
153
+ },
42
154
  conditions: { language: 'en' }
43
155
  },
156
+ // Canadian English - only changes some terminology
44
157
  {
45
- json: { message: 'Bonjour' },
46
- conditions: { language: 'fr' }
158
+ json: {
159
+ nav: { settings: 'Preferences' }, // Only override this
160
+ actions: { save: 'Save, eh!' } // And this
161
+ },
162
+ conditions: { language: 'en', territory: 'CA' }
47
163
  },
164
+ // Admin users get additional actions
48
165
  {
49
- json: { message: 'Hello from Canada' },
50
- conditions: {
51
- language: 'en-CA',
52
- currentTerritory: 'CA'
53
- }
166
+ json: {
167
+ actions: {
168
+ save: 'Save',
169
+ cancel: 'Cancel',
170
+ admin: 'Admin Panel' // Additional action
171
+ }
172
+ },
173
+ conditions: { userType: 'admin' }
54
174
  }
55
175
  ]
56
176
  }).orThrow();
@@ -58,51 +178,552 @@ manager.addResource({
58
178
  // 3. Build the resource manager to prepare for resolution
59
179
  manager.build().orThrow();
60
180
 
61
- // 4. Resolve resources for specific contexts
181
+ // 4. Create resolver and demonstrate different resolution strategies
62
182
  const resolver = TsRes.ResourceResolver.create(manager).orThrow();
63
183
 
64
- // Get resolver for Canadian English context
65
- const caResolver = resolver.withContext({
66
- language: 'en-CA',
67
- currentTerritory: 'CA'
184
+ // Canadian admin user context
185
+ const contextualResolver = resolver.withContext({
186
+ language: 'en',
187
+ territory: 'CA',
188
+ userType: 'admin'
68
189
  }).orThrow();
69
190
 
70
- // Resolve the greeting message - returns: { message: "Hello from Canada" }
71
- const greeting = caResolver.resolveComposedResourceValue('greeting.message').orThrow();
191
+ // COMPOSED RESOLUTION - Merges base + Canadian + admin candidates
192
+ const composed = contextualResolver.resolveComposedResourceValue('user.dashboard').orThrow();
193
+ console.log(composed);
194
+ // Result: {
195
+ // title: 'Dashboard', // from base English
196
+ // nav: { home: 'Home', settings: 'Preferences' }, // base + Canadian override
197
+ // actions: {
198
+ // save: 'Save, eh!', // from Canadian variant
199
+ // cancel: 'Cancel', // from base English
200
+ // admin: 'Admin Panel' // from admin variant
201
+ // }
202
+ // }
203
+
204
+ // SINGLE BEST MATCH - Get most specific candidate only
205
+ const bestMatch = contextualResolver.resolveBestResourceCandidate('user.dashboard').orThrow();
206
+ console.log(bestMatch.candidate.json);
207
+ // Returns just the admin candidate (most specific match)
208
+
209
+ // ALL MATCHES - Get all matching candidates for analysis
210
+ const allMatches = contextualResolver.resolveAllCandidates('user.dashboard').orThrow();
211
+ console.log(`Found ${allMatches.length} matching candidates`);
212
+ // Returns array: [base English, Canadian English, admin]
72
213
  ```
73
214
 
74
- ## Core Concepts
215
+ ## Features
75
216
 
76
- ### Qualifiers
217
+ - **Composed Resource Resolution**: Merge multiple partial candidates into complete resources
218
+ - **Multidimensional Qualifiers**: Organize by language, territory, user types, environments, and custom dimensions
219
+ - **Flexible Resolution Strategies**: Single best match, all matches, or composed resolution
220
+ - **Type-Safe**: Full TypeScript support with comprehensive type definitions
221
+ - **Partial Candidate Support**: Override only what changes, inherit the rest
222
+ - **Priority-Based Matching**: Configurable priority levels for different qualifiers
223
+ - **Resource Merging**: Support for augmenting and replacing merge strategies
224
+ - **File System Integration**: Import resources from file systems with automatic condition inference
225
+ - **Context Filtering**: Generate clean environment-specific bundles
226
+ - **BCP47 Language Support**: Built-in support for BCP47 language tags
227
+ - **Extensible Architecture**: Plugin-based system for custom qualifier types
228
+
229
+ ## Technical Concepts
77
230
 
78
- Qualifiers are dimensions along which resources can vary. Common qualifiers include:
231
+ ### Qualifiers and Conditions
79
232
 
80
- - **Language**: BCP47 language tags (e.g., 'en', 'fr', 'en-CA')
81
- - **Territory**: Geographic regions (e.g., 'US', 'CA', 'FR')
82
- - **Custom**: Application-specific dimensions (e.g., 'theme', 'userType')
233
+ **Qualifiers** define the dimensions along which resources can vary:
83
234
 
84
- ### Conditions
235
+ ```typescript
236
+ // Built-in qualifiers
237
+ - language: 'en', 'fr', 'en-CA' // BCP47 language tags
238
+ - territory: 'US', 'CA', 'FR' // Geographic regions
239
+ - currentTerritory: 'US' // User's current location
240
+
241
+ // Custom qualifiers
242
+ - userType: 'admin', 'user', 'guest'
243
+ - environment: 'dev', 'staging', 'prod'
244
+ - theme: 'light', 'dark'
245
+ - device: 'mobile', 'tablet', 'desktop'
246
+ ```
85
247
 
86
- Conditions determine when a resource candidate applies. They consist of qualifier-value pairs:
248
+ **Conditions** specify when a candidate applies using qualifier-value pairs:
87
249
 
88
250
  ```typescript
89
251
  {
90
- language: 'en-CA',
91
- territory: 'CA',
92
- theme: 'dark'
252
+ language: 'en-CA', // Canadian English
253
+ territory: 'CA', // Canada
254
+ userType: 'admin' // Admin users only
93
255
  }
94
256
  ```
95
257
 
96
- ### Resource Resolution
258
+ ### Resolution Algorithm
259
+
260
+ **Step 1: Individual Condition Scoring**
261
+ Each condition is evaluated against the context to produce a score between 0.0 (NoMatch) and 1.0 (PerfectMatch):
262
+
263
+ ```typescript
264
+ // Language hierarchy matching
265
+ context: { language: "en-US" }
266
+ condition: { language: "en-US" } → 1.0 (PerfectMatch)
267
+ condition: { language: "en" } → 0.8 (partial match - more general)
268
+ condition: { language: "fr-FR" } → 0.0 (NoMatch)
269
+
270
+ // Context list priority (earlier positions beat later)
271
+ context: { languages: ["en-US", "fr-FR"] }
272
+ condition: { language: "en" } → matches first preference (higher score)
273
+ condition: { language: "fr-FR" } → matches second preference (lower score)
274
+
275
+ // Territory/literal hierarchies can also produce partial matches
276
+ ```
277
+
278
+ **Step 2: Condition Set Evaluation**
279
+ A candidate's condition set matches only if ALL conditions score > 0.0. If any condition yields NoMatch (0.0), the entire candidate is eliminated.
280
+
281
+ **Step 3: Priority-First Ranking**
282
+ Each qualifier has a default priority that its conditions inherit. Candidates are ranked using a priority-first system:
283
+
284
+ 1. **Primary rule**: Candidate with the highest priority condition ALWAYS wins
285
+ 2. **Secondary rule**: Score is only used as tie-breaker when priorities are equal
286
+ 3. **Comparison**: Sort conditions within each candidate by (priority DESC, score DESC), then compare candidates pairwise
287
+
288
+ ```typescript
289
+ // Context: { language: "en-CA", territory: "CA" }
290
+
291
+ // Example: Language (priority 600) beats Territory (priority 400)
292
+ Candidate A: { language: "en" } → priority 600, score 0.8 (partial match with en-CA)
293
+ Candidate B: { territory: "CA" } → priority 400, score 1.0 (perfect match)
294
+ // Winner: Candidate A (higher priority wins despite lower score)
295
+
296
+ // When priorities match, score breaks ties
297
+ Candidate A: { language: "en-CA" } → priority 600, score 1.0 (perfect match)
298
+ Candidate B: { language: "en" } → priority 600, score 0.8 (partial match)
299
+ // Winner: Candidate A (same priority, higher score)
300
+
301
+ // Multiple conditions: ALL must match, highest priority condition determines rank
302
+ Candidate C: { language: "en", territory: "CA" } → both conditions match
303
+ // Conditions: language (priority 600, score 0.8), territory (priority 400, score 1.0)
304
+ // Candidate rank determined by highest: priority 600, score 0.8
305
+ ```
306
+
307
+ **Step 4: Fallback Resolution (when no candidates match)**
308
+ If no candidates pass Step 2 (all are eliminated by `NoMatch` conditions), the system falls back to resolution considering defaults:
309
+
310
+ ```typescript
311
+ // Qualifier configured with default language = "en-US"
312
+ // Context: { language: "de-DE" } (German - no candidates match)
313
+
314
+ // During fallback, NoMatch conditions get promoted to their scoreAsDefault:
315
+ condition: { language: "en-US" } → scoreAsDefault = 1.0 (perfect match with default)
316
+ condition: { language: "fr-FR" } → scoreAsDefault = 0.0 (no match with default)
317
+ condition: { language: "en-GB" } → scoreAsDefault = 0.7 (partial match with default)
318
+
319
+ // Now English candidates become available for German users as intelligent fallbacks
320
+ ```
321
+
322
+ The `scoreAsDefault` is automatically calculated when creating conditions by comparing the condition's value against the qualifier's configured default value using the same 0.0-1.0 scoring logic.
323
+
324
+ **Step 5: Resolution Strategy Application**
325
+
326
+ - **Single Best Match**: Return the top-ranked candidate only
327
+ - **All Candidates**: Return all candidates that passed matching (Step 2 or fallback Step 4), with their rankings
328
+ - **Composed Resolution**: Merge all matching candidates in rank order (lowest rank merged first, highest rank applied last)
329
+
330
+ ## Creating Resources
331
+
332
+ The most flexible and maintainable way to manage resources is through filesystem import, which automatically infers conditions and resource IDs from your directory structure.
333
+
334
+ ### Filesystem Import (Recommended)
335
+
336
+ Organize your resources in a directory structure that reflects their conditions. Since values like "CA" or "FR" could be either language or territory, use explicit qualifiers or tokens:
337
+
338
+ ```
339
+ resources/
340
+ ├── language=en/ # language: "en"
341
+ │ ├── dashboard.json # id: "dashboard"
342
+ │ ├── profile.json # id: "profile"
343
+ │ └── territory=CA/ # language: "en", territory: "CA"
344
+ │ └── dashboard.json # id: "dashboard" (Canadian override)
345
+ ├── language=fr/ # language: "fr"
346
+ │ ├── dashboard.json # id: "dashboard"
347
+ │ └── territory=CA/ # language: "fr", territory: "CA"
348
+ │ └── dashboard.json # id: "dashboard" (Quebec variant)
349
+ └── _resources.json # Resource collection or configuration
350
+ ```
351
+
352
+ Or with tokens configured for brevity:
353
+
354
+ ```
355
+ resources/
356
+ ├── lang=en/ # language: "en" (using token)
357
+ │ ├── dashboard.json
358
+ │ └── terr=CA/ # territory: "CA" (using token)
359
+ │ └── dashboard.json
360
+ ├── lang=fr/ # language: "fr" (using token)
361
+ │ └── terr=CA/ # territory: "CA" (using token)
362
+ │ └── dashboard.json
363
+ └── resources-config.json # Configuration with token definitions
364
+ ```
365
+
366
+ Import resources with automatic context inference:
367
+
368
+ ```typescript
369
+ import * as TsRes from '@fgv/ts-res';
370
+
371
+ // Create configuration with tokens for cleaner folder names
372
+ const config = TsRes.Config.SystemConfiguration.create({
373
+ name: 'my-app',
374
+ qualifierTypes: [
375
+ TsRes.QualifierTypes.LanguageQualifierType.create().orThrow(),
376
+ TsRes.QualifierTypes.TerritoryQualifierType.create().orThrow()
377
+ ],
378
+ qualifiers: [
379
+ { name: 'language', typeName: 'language', defaultPriority: 600, token: 'lang' },
380
+ { name: 'territory', typeName: 'territory', defaultPriority: 400, token: 'terr' }
381
+ ],
382
+ resourceTypes: [
383
+ TsRes.ResourceTypes.JsonResourceType.create().orThrow()
384
+ ]
385
+ }).orThrow();
386
+
387
+ const manager = TsRes.Resources.ResourceManagerBuilder.create(config).orThrow();
388
+
389
+ // Import from filesystem - conditions are inferred from folder names
390
+ const importManager = TsRes.Import.ImportManager.create({
391
+ filetree: fileTree,
392
+ resources: manager,
393
+ importers: TsRes.Import.ImportManager.getDefaultImporters(),
394
+ initialContext: TsRes.Import.ImportContext.create().orThrow()
395
+ }).orThrow();
396
+
397
+ const result = importManager.importFromFileSystem({
398
+ type: 'path',
399
+ path: './resources'
400
+ }).orThrow();
401
+
402
+ // Result: All resources loaded with appropriate conditions
403
+ // - resources/lang=en/dashboard.json → { language: "en" }
404
+ // - resources/lang=en/terr=CA/dashboard.json → { language: "en", territory: "CA" }
405
+ ```
406
+
407
+ ### Resource File Formats
408
+
409
+ Resources can be stored in two formats:
97
410
 
98
- The library automatically selects the most appropriate resource based on:
411
+ **1. Resource Collection Format** (`_resources.json`):
99
412
 
100
- 1. **Specificity**: More specific conditions take precedence
101
- 2. **Priority**: Higher priority qualifiers are weighted more heavily
102
- 3. **Fallback**: Graceful degradation when exact matches aren't found
413
+ Resource collections are flexible containers that can include:
414
+ - **Loose resources**: Complete resources with their candidates
415
+ - **Loose candidates**: Additional candidates to merge with existing resources
416
+ - **Nested collections**: Collections within collections for organization
417
+
418
+ ```json
419
+ {
420
+ "resources": [
421
+ {
422
+ "id": "dashboard",
423
+ "candidates": [
424
+ {
425
+ "json": { "title": "Dashboard", "welcome": "Welcome!" },
426
+ "conditions": { "language": "en" }
427
+ }
428
+ ]
429
+ }
430
+ ],
431
+ "candidates": [
432
+ {
433
+ "resourceId": "dashboard",
434
+ "json": { "welcome": "Welcome, eh!" },
435
+ "conditions": { "language": "en", "territory": "CA" }
436
+ },
437
+ {
438
+ "resourceId": "profile",
439
+ "json": { "title": "User Profile" },
440
+ "conditions": { "language": "en" }
441
+ }
442
+ ],
443
+ "collections": [
444
+ {
445
+ "name": "legal",
446
+ "resources": [
447
+ {
448
+ "id": "legal.privacy",
449
+ "candidates": [
450
+ {
451
+ "json": { "url": "/privacy.html" },
452
+ "conditions": {}
453
+ }
454
+ ]
455
+ }
456
+ ]
457
+ }
458
+ ]
459
+ }
460
+ ```
461
+
462
+ The importer merges all these elements:
463
+ - Loose candidates are added to their target resources
464
+ - Nested collections are flattened with appropriate ID prefixing
465
+ - Multiple candidates for the same resource are merged
466
+
467
+ **2. Resource Tree Format** (individual JSON files):
468
+ ```json
469
+ // resources/en/dashboard.json
470
+ {
471
+ "title": "Dashboard",
472
+ "welcome": "Welcome!",
473
+ "nav": {
474
+ "home": "Home",
475
+ "settings": "Settings"
476
+ }
477
+ }
478
+
479
+ // resources/en/CA/dashboard.json (partial override)
480
+ {
481
+ "welcome": "Welcome, eh!",
482
+ "nav": {
483
+ "settings": "Preferences" // Canadian terminology
484
+ }
485
+ }
486
+ ```
487
+
488
+ ### Context Collection During Import
489
+
490
+ The importer recognizes qualifier values in folder and file names using specific patterns:
491
+
492
+ **1. Default Pattern: `qualifier=value`**
493
+ ```
494
+ resources/
495
+ ├── language=en/ # { language: "en" }
496
+ ├── language=fr-CA/ # { language: "fr-CA" }
497
+ ├── territory=US/ # { territory: "US" }
498
+ └── environment=production/ # { environment: "production" }
499
+ ```
500
+
501
+ **2. Token Shorthand (when configured)**
502
+ Qualifiers can define a "token" for brevity:
503
+ ```typescript
504
+ // Configuration
505
+ {
506
+ qualifiers: [
507
+ { name: "language", token: "lang" }, // Allow "lang=en"
508
+ { name: "homeTerritory", token: "home" }, // Allow "home=US"
509
+ { name: "environment", token: "env" } // Allow "env=prod"
510
+ ]
511
+ }
512
+ ```
513
+ ```
514
+ resources/
515
+ ├── lang=en-US/ # { language: "en-US" } via token
516
+ ├── home=CA/ # { homeTerritory: "CA" } via token
517
+ └── env=production/ # { environment: "production" } via token
518
+ ```
519
+
520
+ **3. Optional Token Pattern (use with caution!)**
521
+ When `tokenIsOptional: true`, the qualifier name/token can be omitted:
522
+ ```typescript
523
+ {
524
+ qualifiers: [
525
+ {
526
+ name: "homeTerritory",
527
+ token: "home",
528
+ tokenIsOptional: true // DANGER: "CA" alone will match!
529
+ }
530
+ ]
531
+ }
532
+ ```
533
+ ```
534
+ resources/
535
+ ├── CA/ # { homeTerritory: "CA" } - matches without prefix!
536
+ ├── home=US/ # { homeTerritory: "US" } - explicit still works
537
+ └── strings/ # Regular folder (if "strings" isn't a valid territory)
538
+ ```
539
+
540
+ **⚠️ Warning**: Be very cautious with `tokenIsOptional`. If your qualifier accepts permissive values (like territories that could be any 2-letter code), ordinary folder names might be misinterpreted as qualifier values.
541
+
542
+ **4. Context Accumulation**
543
+ Nested folders inherit and combine parent conditions:
544
+ ```
545
+ resources/
546
+ └── language=en/
547
+ └── territory=CA/
548
+ └── role=admin/ # { language: "en", territory: "CA", role: "admin" }
549
+ ```
550
+
551
+ **5. Resource ID Prefixing**
552
+ Non-qualifier folders become part of the resource ID:
553
+ ```
554
+ resources/
555
+ ├── legal/ # Not a qualifier, becomes ID prefix
556
+ │ ├── privacy.json # id: "legal.privacy"
557
+ │ └── territory=EU/
558
+ │ └── privacy.json # id: "legal.privacy", conditions: { territory: "EU" }
559
+ └── language=fr/
560
+ └── legal/
561
+ └── privacy.json # id: "legal.privacy", conditions: { language: "fr" }
562
+ ```
563
+
564
+ ### Manual Resource Creation (Alternative)
565
+
566
+ For simple cases or dynamic resources, you can create them programmatically:
567
+
568
+ ```typescript
569
+ manager.addResource({
570
+ id: 'api.config',
571
+ resourceTypeName: 'json',
572
+ candidates: [
573
+ {
574
+ json: { url: 'localhost:3000' },
575
+ conditions: { environment: 'development' }
576
+ },
577
+ {
578
+ json: { url: 'api.production.com' },
579
+ conditions: { environment: 'production' }
580
+ }
581
+ ]
582
+ }).orThrow();
583
+ ```
584
+
585
+ This is useful for configuration that changes based on deployment environment or resources generated at runtime.
103
586
 
104
587
  ## Advanced Usage
105
588
 
589
+ ### Custom Priority and Default Scoring
590
+
591
+ While priority and `scoreAsDefault` are typically determined automatically by qualifier configuration, they can be overridden for advanced use cases:
592
+
593
+ **Use Case 1: Custom Priority for Territory-Based Content**
594
+
595
+ For legal documents and branding, territory often matters more than language:
596
+
597
+ ```typescript
598
+ // Configuration: Elevate territory priority for legal/brand content
599
+ const territoryPriorityConfig = TsRes.Config.SystemConfiguration.create({
600
+ name: 'territory-priority',
601
+ qualifierTypes: [
602
+ TsRes.QualifierTypes.LanguageQualifierType.create().orThrow(),
603
+ TsRes.QualifierTypes.TerritoryQualifierType.create().orThrow()
604
+ ],
605
+ qualifiers: [
606
+ { name: 'language', typeName: 'language', defaultPriority: 600 },
607
+ { name: 'currentTerritory', typeName: 'territory', defaultPriority: 700 } // Higher than language
608
+ ],
609
+ resourceTypes: [TsRes.ResourceTypes.JsonResourceType.create().orThrow()]
610
+ }).orThrow();
611
+
612
+ // Legal document with territory-specific versions
613
+ manager.addResource({
614
+ id: 'legal.privacy-policy',
615
+ candidates: [
616
+ {
617
+ json: { url: '/privacy-us.html' },
618
+ conditions: { currentTerritory: 'US' } // Priority 700
619
+ },
620
+ {
621
+ json: { url: '/privacy-ca.html' },
622
+ conditions: { currentTerritory: 'CA' } // Priority 700
623
+ },
624
+ {
625
+ json: { url: '/privacy-generic-fr.html' },
626
+ conditions: { language: 'fr' } // Priority 600 (lower)
627
+ }
628
+ ]
629
+ }).orThrow();
630
+
631
+ // Context: { language: 'fr-CA', currentTerritory: 'CA' }
632
+ // Winner: Canadian privacy policy (territory priority 700 > language priority 600)
633
+ // Result: { url: '/privacy-ca.html' } - legally correct for Canada
634
+ ```
635
+
636
+ **Use Case 2: Custom scoreAsDefault for "Original Language" Scenarios**
637
+
638
+ For content like quotes or historical documents, you might want to preserve the original language:
639
+
640
+ ```typescript
641
+ // Configuration: Custom scoreAsDefault for originalLanguage qualifier
642
+ const originalLanguageConfig = TsRes.Config.SystemConfiguration.create({
643
+ name: 'preserve-original',
644
+ qualifierTypes: [
645
+ TsRes.QualifierTypes.LanguageQualifierType.create().orThrow()
646
+ ],
647
+ qualifiers: [
648
+ { name: 'language', typeName: 'language', defaultPriority: 600, defaultValue: 'en' },
649
+ { name: 'originalLanguage', typeName: 'language', defaultPriority: 650, defaultValue: 'original' }
650
+ ],
651
+ resourceTypes: [TsRes.ResourceTypes.JsonResourceType.create().orThrow()]
652
+ }).orThrow();
653
+
654
+ // Historical quote that should prefer original language
655
+ manager.addResource({
656
+ id: 'quotes.einstein',
657
+ candidates: [
658
+ {
659
+ json: { text: 'Gott würfelt nicht', attribution: 'Einstein, 1926' },
660
+ conditions: { originalLanguage: 'de' } // German original, scoreAsDefault = 1.0
661
+ },
662
+ {
663
+ json: { text: 'God does not play dice', attribution: 'Einstein, 1926' },
664
+ conditions: { language: 'en' } // English translation, scoreAsDefault = calculated from default
665
+ }
666
+ ]
667
+ }).orThrow();
668
+
669
+ // Context: { language: 'fr' } (French user, no direct matches)
670
+ // Fallback resolution: originalLanguage='de' gets scoreAsDefault=1.0, language='en' gets lower score
671
+ // Winner: German original (higher scoreAsDefault in fallback resolution)
672
+ // Result: { text: 'Gott würfelt nicht', attribution: 'Einstein, 1926' }
673
+ ```
674
+
675
+ These patterns ensure that content with special requirements (legal compliance, historical accuracy) gets appropriate priority treatment.
676
+
677
+ **Individual Condition Overrides for Outlier Cases**
678
+
679
+ For rare exceptions, you can override priority or scoreAsDefault directly in individual condition declarations without creating new configurations:
680
+
681
+ ```typescript
682
+ // Most content uses standard language/territory priorities
683
+ // But this specific legal resource needs territory to take precedence
684
+
685
+ manager.addResource({
686
+ id: 'legal.gdpr-notice',
687
+ candidates: [
688
+ {
689
+ json: { text: 'GDPR applies', url: '/gdpr-eu.html' },
690
+ conditions: {
691
+ territory: {
692
+ qualifier: 'territory',
693
+ operator: 'eq',
694
+ value: 'EU',
695
+ priority: 700 // Override: higher than normal territory priority
696
+ }
697
+ }
698
+ },
699
+ {
700
+ json: { text: 'Generic privacy notice', url: '/privacy.html' },
701
+ conditions: { language: 'en' } // Uses default language priority (600)
702
+ }
703
+ ]
704
+ }).orThrow();
705
+
706
+ // Individual scoreAsDefault override for a specific quote
707
+ manager.addResource({
708
+ id: 'quotes.shakespeare',
709
+ candidates: [
710
+ {
711
+ json: { text: 'To be or not to be', source: 'Hamlet' },
712
+ conditions: {
713
+ originalLanguage: {
714
+ qualifier: 'originalLanguage',
715
+ operator: 'eq',
716
+ value: 'en',
717
+ scoreAsDefault: 1.0 // Override: perfect fallback score for English original
718
+ }
719
+ }
720
+ }
721
+ ]
722
+ }).orThrow();
723
+ ```
724
+
725
+ This approach is more efficient than creating separate configurations when you only have occasional exceptions to the standard priority rules.
726
+
106
727
  ### Resource Building with ResourceBuilder
107
728
 
108
729
  ```typescript