chronolizer 0.3.0 → 0.4.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Chronolizer
2
2
 
3
- Chronolizer is an Effect 4 library that converts natural-language date ranges to compact date-math filters and back. It supports open ranges, autocomplete, typo correction, and eight languages.
3
+ Chronolizer is an Effect 4 library that converts natural-language date ranges to compact date filters and back. It supports ranges without a start or end, autocomplete, spelling correction, and eight languages.
4
4
 
5
5
  ```text
6
6
  year to date → { gte: "now/y", lte: "now" }
@@ -9,11 +9,11 @@ since January 2025 → { gte: "2025-01-01" }
9
9
  ```
10
10
 
11
11
  > [!NOTE]
12
- > Chronolizer currently uses `effect@4.0.0-rc.112`. The API can change while Effect v4 has release-candidate status.
12
+ > Chronolizer currently uses `effect@4.0.0-rc.112`. Effect 4 is not stable yet, so its API can change.
13
13
 
14
14
  ## Install Chronolizer
15
15
 
16
- Chronolizer requires Effect 4 and ESM. Install both packages as direct dependencies.
16
+ Chronolizer requires Effect 4 and uses ES modules. Install both packages:
17
17
 
18
18
  ```sh
19
19
  pnpm add chronolizer effect@4.0.0-rc.112
@@ -21,7 +21,7 @@ pnpm add chronolizer effect@4.0.0-rc.112
21
21
 
22
22
  ## Parse your first date range
23
23
 
24
- English is available from the main package entry.
24
+ English is available from the main package.
25
25
 
26
26
  ```ts
27
27
  import { EnglishLanguageLayer, formatFilter, parseNatural } from "chronolizer";
@@ -43,7 +43,7 @@ Effect.runPromise(program);
43
43
 
44
44
  ### Parse other languages
45
45
 
46
- Import each non-English language from its own package entry. This keeps unused languages out of your bundle.
46
+ Import each non-English language separately. This keeps unused language code out of your build.
47
47
 
48
48
  ```ts
49
49
  import { formatFilter, parseNatural } from "chronolizer";
@@ -60,7 +60,7 @@ Effect.runPromise(program);
60
60
  // { gte: "now/y", lte: "now" }
61
61
  ```
62
62
 
63
- Use `languagePluginsLayer` when one application needs more than one language:
63
+ Use `languagePluginsLayer` to combine languages:
64
64
 
65
65
  ```ts
66
66
  import { EnglishLanguage, languagePluginsLayer, parseNatural } from "chronolizer";
@@ -77,7 +77,7 @@ Effect.runPromise(program);
77
77
 
78
78
  ### Format a range as natural language
79
79
 
80
- `formatNatural` returns the canonical phrase for a range. It preserves the meaning, but it does not reproduce the original wording. Absolute days use the locale's numeric `Intl.DateTimeFormat` form.
80
+ `formatNatural` returns one standard phrase for a range. It preserves the meaning, but it does not reproduce the original wording. Absolute days use the language's numeric `Intl.DateTimeFormat` form.
81
81
 
82
82
  ```ts
83
83
  import { formatNatural, parseFilter } from "chronolizer";
@@ -96,7 +96,7 @@ Effect.runPromise(program);
96
96
 
97
97
  ### Add autocomplete
98
98
 
99
- `suggestNatural` returns valid canonical phrases and their semantic ranges.
99
+ `suggestNatural` returns valid standard phrases and their date ranges.
100
100
 
101
101
  ```ts
102
102
  import { EnglishLanguageLayer, suggestNatural } from "chronolizer";
@@ -115,7 +115,7 @@ The default limit is 10. The maximum limit is 100. A nonpositive or invalid limi
115
115
 
116
116
  ### Accept spelling errors
117
117
 
118
- Set `typoMode` to `"tolerant"` for conservative correction:
118
+ Set `typoMode` to `"tolerant"` to correct close spelling errors:
119
119
 
120
120
  ```ts
121
121
  const program = parseNatural("januray of last yaer", {
@@ -124,11 +124,11 @@ const program = parseNatural("januray of last yaer", {
124
124
  });
125
125
  ```
126
126
 
127
- The successful value contains its `quality`, applied `corrections`, and any semantic `alternatives`. Strict mode is the default and does not correct the input.
127
+ The result contains its `quality`, applied `corrections`, and any `alternatives` with a different meaning. Strict mode is the default and does not correct the input.
128
128
 
129
- ### Exclude future relative ranges
129
+ ### Exclude future ranges
130
130
 
131
- Set `allowFuture` to `false` to reject explicit relative future ranges:
131
+ Set `allowFuture` to `false` to reject periods after the current period:
132
132
 
133
133
  ```ts
134
134
  const program = parseNatural("next 3 months", {
@@ -137,7 +137,7 @@ const program = parseNatural("next 3 months", {
137
137
  });
138
138
  ```
139
139
 
140
- This option rejects forms such as `next month`, `next 3 weeks`, and `in 3 years`. It keeps complete current calendar periods such as `today`, `this week`, and `this month`. It does not change them to period-to-date ranges.
140
+ This option rejects forms such as `next month`, `next 3 weeks`, and `in 3 years`. It keeps complete current calendar periods such as `today`, `this week`, and `this month`. It does not change them to ranges that end at the current time.
141
141
 
142
142
  The option does not compare fixed dates, such as `January 2099`, with the current date. Parsing does not read the clock.
143
143
 
@@ -145,7 +145,7 @@ The same option is available in `suggestNatural`.
145
145
 
146
146
  ### Resolve a range to dates
147
147
 
148
- Provide an Effect time zone when you resolve relative expressions:
148
+ Provide an Effect time zone when you calculate ranges based on the current date:
149
149
 
150
150
  ```ts
151
151
  import { parseFilter, resolve } from "chronolizer";
@@ -159,7 +159,7 @@ const program = parseFilter({ gte: "now/y", lte: "now" }).pipe(
159
159
  Effect.runPromise(program);
160
160
  ```
161
161
 
162
- `resolve` uses the Effect clock and `DateTime.CurrentTimeZone`. It does not use the host time zone without your decision.
162
+ `resolve` uses the Effect clock and `DateTime.CurrentTimeZone`. It uses only the time zone that you provide.
163
163
 
164
164
  ### Validate an external filter
165
165
 
@@ -176,18 +176,19 @@ const program = Schema.decodeUnknownEffect(DateFilter)(externalInput).pipe(
176
176
  Effect.runPromise(program);
177
177
  ```
178
178
 
179
- ## Supported expressions
179
+ ## Supported input
180
180
 
181
- Supported input families include:
181
+ You can parse:
182
182
 
183
- - calendar days, weeks, months, quarters, and years;
184
- - rolling ranges, such as `last 3 months`;
185
- - calendar offsets, such as `30 months ago`;
186
- - period-to-date ranges, such as `year to date`;
187
- - fixed months, quarters, years, and explicit date intervals;
183
+ - days, weeks, months, quarters, and years;
184
+ - ranges with a length, such as `last 3 months`;
185
+ - single periods in the past or future, such as `30 months ago`;
186
+ - ranges from a period start to now, such as `year to date`;
187
+ - fixed months, quarters, years, and date ranges;
188
188
  - named days with or without a year, such as `January 12` and `12th of January`;
189
- - open ranges, such as `since January 2025` and `from January 12`;
190
- - compositional boundaries, such as `the day before January 12`;
189
+ - ranges without one end, such as `since January 2025` and `from January 12`;
190
+ - written counts from one through ninety-nine, such as `two weeks ago` and `twenty-one weeks ago`;
191
+ - combined phrases, such as `the day before January 12` and `yesterday two weeks ago`;
191
192
  - period starts, period ends, and weekends;
192
193
  - abbreviated month names and common equivalent phrases.
193
194
 
@@ -206,16 +207,16 @@ Examples:
206
207
 
207
208
  ## Supported languages
208
209
 
209
- | Language | Locale | Import |
210
- | -------- | ------ | ----------------------------------------- |
211
- | English | `en` | `chronolizer` or `chronolizer/locales/en` |
212
- | German | `de` | `chronolizer/locales/de` |
213
- | Spanish | `es` | `chronolizer/locales/es` |
214
- | French | `fr` | `chronolizer/locales/fr` |
215
- | Dutch | `nl` | `chronolizer/locales/nl` |
216
- | Turkish | `tr` | `chronolizer/locales/tr` |
217
- | Czech | `cs` | `chronolizer/locales/cs` |
218
- | Polish | `pl` | `chronolizer/locales/pl` |
210
+ | Language | Code | Import |
211
+ | -------- | ---- | ----------------------------------------- |
212
+ | English | `en` | `chronolizer` or `chronolizer/locales/en` |
213
+ | German | `de` | `chronolizer/locales/de` |
214
+ | Spanish | `es` | `chronolizer/locales/es` |
215
+ | French | `fr` | `chronolizer/locales/fr` |
216
+ | Dutch | `nl` | `chronolizer/locales/nl` |
217
+ | Turkish | `tr` | `chronolizer/locales/tr` |
218
+ | Czech | `cs` | `chronolizer/locales/cs` |
219
+ | Polish | `pl` | `chronolizer/locales/pl` |
219
220
 
220
221
  ## Date filter reference
221
222
 
@@ -229,32 +230,34 @@ A date filter has at least one bound. It can have one lower bound and one upper
229
230
  | `lte` | On or before the value |
230
231
 
231
232
  An expression starts with `now` or an ISO date. Operations run from left to right.
233
+ Calendar shifts are not combined or canceled. For example, `2025-01-31||+1M+1M`
234
+ resolves to March 28, not March 31. Each month shift clamps the day to the last
235
+ valid day of that month. Time-zone gaps can also affect intermediate dates.
232
236
 
233
237
  ```text
234
- expression := anchor operation*
235
- anchor := "now" | YYYY-MM-DD | YYYY-MM-DD "||"
238
+ expression := "now" operation* | YYYY-MM-DD | YYYY-MM-DD "||" operation+
236
239
  operation := ("+" | "-") positiveInteger unit | "/" unit
237
240
  unit := "d" | "w" | "M" | "q" | "y"
238
241
  ```
239
242
 
240
243
  `/unit` moves a value to the start of its calendar unit. Add `||` before operations on a fixed date, for example `2025-01-01||+1M`.
241
244
 
242
- Complete calendar periods use a half-open interval: the lower bound is inclusive and the next period start is exclusive. Weeks start on Monday.
245
+ A complete calendar period includes its start but excludes the start of the next period. Weeks start on Monday.
243
246
 
244
247
  A named day without a year uses the current calendar year. Write the year for February 29 because parsing does not read the clock.
245
248
 
246
249
  ## Main API
247
250
 
248
- | Export | Purpose |
249
- | --------------------- | ------------------------------------------------------------------- |
250
- | `parseNatural` | Parse complete natural-language input to a semantic range |
251
- | `formatNatural` | Render a supported range as a canonical phrase |
252
- | `suggestNatural` | Return autocomplete suggestions and semantic ranges |
253
- | `parseFilter` | Parse a date filter to a semantic range |
254
- | `formatFilter` | Format a semantic range as a date filter |
255
- | `resolve` | Resolve a semantic range with an Effect clock and time zone |
256
- | `DateFilter` | Validate external date-filter data with Effect Schema |
257
- | `DateRangeFromFilter` | Decode and encode a filter through one Effect Schema transformation |
251
+ | Export | Purpose |
252
+ | --------------------- | ---------------------------------------------------------- |
253
+ | `parseNatural` | Parse complete natural-language input to a date range |
254
+ | `formatNatural` | Write a supported range as one standard phrase |
255
+ | `suggestNatural` | Return autocomplete suggestions and date ranges |
256
+ | `parseFilter` | Parse a date filter to a date range |
257
+ | `formatFilter` | Format a date range as a date filter |
258
+ | `resolve` | Calculate dates with an Effect clock and time zone |
259
+ | `DateFilter` | Validate external date-filter data with Effect Schema |
260
+ | `DateRangeFromFilter` | Convert a filter in both directions with one Effect Schema |
258
261
 
259
262
  ## License
260
263
 
package/dist/ast/fold.mjs CHANGED
@@ -1,19 +1,17 @@
1
- import { DateLiteral, GreaterThanOrEqual, LessThan, Now, Shift, StartOf } from "./schemas.mjs";
2
- import { Match, Schema, absurd } from "effect";
1
+ import { GreaterThanOrEqual, LessThan, Now, Shift, StartOf } from "./schemas.mjs";
2
+ import { Match, Schema } from "effect";
3
3
  //#region src/ast/fold.ts
4
- const isDateLiteral = Schema.is(DateLiteral);
5
4
  const isGreaterThanOrEqual = Schema.is(GreaterThanOrEqual);
6
5
  const isLessThan = Schema.is(LessThan);
7
6
  const isNow = Schema.is(Now);
8
7
  const isShift = Schema.is(Shift);
9
8
  const isStartOf = Schema.is(StartOf);
10
- const foldInstant = (expression, algebra) => {
11
- if (isNow(expression)) return algebra.now();
12
- if (isDateLiteral(expression)) return algebra.dateLiteral(expression.value);
13
- if (isShift(expression)) return algebra.shift(foldInstant(expression.base, algebra), expression.amount, expression.unit);
14
- if (isStartOf(expression)) return algebra.startOf(foldInstant(expression.base, algebra), expression.unit);
15
- return absurd(expression);
16
- };
9
+ const foldInstant = (expression, algebra) => Match.typeTags()({
10
+ Now: () => algebra.now(),
11
+ DateLiteral: (literal) => algebra.dateLiteral(literal.value),
12
+ Shift: (operation) => algebra.shift(foldInstant(operation.base, algebra), operation.amount, operation.unit),
13
+ StartOf: (operation) => algebra.startOf(foldInstant(operation.base, algebra), operation.unit)
14
+ })(expression);
17
15
  const shiftOffset = (base, amount, unit) => Match.value(unit).pipe(Match.when("year", () => ({
18
16
  ...base,
19
17
  months: base.months + amount * 12
@@ -1,21 +1,12 @@
1
- import { Shift } from "./schemas.mjs";
2
1
  import { boundedRange, dateLiteral, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, lowerOpenRange, now, shift, startOf, upperOpenRange } from "./constructors.mjs";
3
- import { Match, Schema } from "effect";
2
+ import { foldInstant } from "./fold.mjs";
3
+ import { Match } from "effect";
4
4
  //#region src/ast/normalize.ts
5
- const isShift = Schema.is(Shift);
6
- const normalizeInstant = (expression) => Match.valueTags(expression, {
7
- Now: () => now(),
8
- DateLiteral: (literal) => dateLiteral(literal.value),
9
- StartOf: (operation) => startOf(normalizeInstant(operation.base), operation.unit),
10
- Shift: (operation) => {
11
- const base = normalizeInstant(operation.base);
12
- if (operation.amount === 0) return base;
13
- if (isShift(base) && base.unit === operation.unit) {
14
- const amount = base.amount + operation.amount;
15
- if (Number.isSafeInteger(amount)) return normalizeInstant(shift(base.base, amount, operation.unit));
16
- }
17
- return shift(base, operation.amount, operation.unit);
18
- }
5
+ const normalizeInstant = (expression) => foldInstant(expression, {
6
+ now,
7
+ dateLiteral,
8
+ startOf,
9
+ shift: (base, amount, unit) => amount === 0 ? base : shift(base, amount, unit)
19
10
  });
20
11
  const normalizeLower = (bound) => Match.valueTags(bound, {
21
12
  GreaterThan: (value) => greaterThan(normalizeInstant(value.value)),
@@ -31,13 +31,12 @@ const formatUpper = Match.typeTags()({
31
31
  LessThanOrEqual: (bound) => DateFilter.make({ lte: formatInstantExpression(bound.value) })
32
32
  });
33
33
  const formatFilter = (range) => {
34
- const normalized = normalizeRange(range);
35
- if (normalized.lower !== void 0 && normalized.upper !== void 0) return DateFilter.make({
36
- ...formatLower(normalized.lower),
37
- ...formatUpper(normalized.upper)
34
+ if (range.lower !== void 0 && range.upper !== void 0) return DateFilter.make({
35
+ ...formatLower(range.lower),
36
+ ...formatUpper(range.upper)
38
37
  });
39
- if (normalized.lower !== void 0) return formatLower(normalized.lower);
40
- return formatUpper(normalized.upper);
38
+ if (range.lower !== void 0) return formatLower(range.lower);
39
+ return formatUpper(range.upper);
41
40
  };
42
41
  const rangeKey = (range) => {
43
42
  const filter = formatFilter(range);
@@ -1,7 +1,6 @@
1
1
  import { isIsoDate } from "../ast/schemas.mjs";
2
2
  import { dateLiteral, now, shift, startOf } from "../ast/constructors.mjs";
3
3
  import { foldInstant } from "../ast/fold.mjs";
4
- import { normalizeInstant } from "../ast/normalize.mjs";
5
4
  import { FilterExpressionParseError } from "./errors.mjs";
6
5
  import { Effect, Match } from "effect";
7
6
  //#region src/filter/expression.ts
@@ -14,25 +13,31 @@ const failAt = (input, offset, expected) => Effect.fail(new FilterExpressionPars
14
13
  }));
15
14
  const isDigit = (value) => value >= "0" && value <= "9";
16
15
  const isFirstPositiveDigit = (value) => value >= "1" && value <= "9";
16
+ const parseAnchor = Effect.fn(function* (input) {
17
+ if (input.startsWith("now")) return {
18
+ expression: now(),
19
+ cursor: 3,
20
+ fixed: false
21
+ };
22
+ const candidate = input.slice(0, 10);
23
+ if (!isIsoDate(candidate)) return yield* failAt(input, 0, "\"now\" or an ISO date (YYYY-MM-DD)");
24
+ return {
25
+ expression: dateLiteral(candidate),
26
+ cursor: 10,
27
+ fixed: true
28
+ };
29
+ });
30
+ const operationStart = Effect.fn(function* (input, anchor) {
31
+ if (!anchor.fixed || anchor.cursor === input.length) return anchor.cursor;
32
+ if (input.slice(anchor.cursor, anchor.cursor + 2) !== "||") return yield* failAt(input, anchor.cursor, "\"||\" before date operations");
33
+ const cursor = anchor.cursor + 2;
34
+ if (cursor === input.length) return yield* failAt(input, cursor, "an operation beginning with \"+\", \"-\", or \"/\"");
35
+ return cursor;
36
+ });
17
37
  const parseInstantExpression = Effect.fn(function* (input) {
18
- let cursor = 0;
19
- let expression;
20
- let fixedAnchor = false;
21
- if (input.startsWith("now")) {
22
- expression = now();
23
- cursor = 3;
24
- } else {
25
- const candidate = input.slice(0, 10);
26
- if (!isIsoDate(candidate)) return yield* failAt(input, 0, "\"now\" or an ISO date (YYYY-MM-DD)");
27
- expression = dateLiteral(candidate);
28
- cursor = 10;
29
- fixedAnchor = true;
30
- }
31
- if (fixedAnchor && cursor < input.length) {
32
- if (input.slice(cursor, cursor + 2) !== "||") return yield* failAt(input, cursor, "\"||\" before date operations");
33
- cursor += 2;
34
- if (cursor === input.length) return yield* failAt(input, cursor, "an operation beginning with \"+\", \"-\", or \"/\"");
35
- }
38
+ const anchor = yield* parseAnchor(input);
39
+ let expression = anchor.expression;
40
+ let cursor = yield* operationStart(input, anchor);
36
41
  while (cursor < input.length) {
37
42
  const operator = input[cursor];
38
43
  if (operator === "/") {
@@ -60,7 +65,7 @@ const appendOperation = (base, operation) => ({
60
65
  text: `${base.text}${base.fixedAnchor ? "||" : ""}${operation}`,
61
66
  fixedAnchor: false
62
67
  });
63
- const formatInstantExpression = (expression) => foldInstant(normalizeInstant(expression), {
68
+ const formatInstantExpression = (expression) => foldInstant(expression, {
64
69
  now: () => ({
65
70
  text: "now",
66
71
  fixedAnchor: false
@@ -69,7 +74,7 @@ const formatInstantExpression = (expression) => foldInstant(normalizeInstant(exp
69
74
  text: value,
70
75
  fixedAnchor: true
71
76
  }),
72
- shift: (base, amount, unit) => appendOperation(base, `${amount < 0 ? "-" : "+"}${Math.abs(amount)}${symbolFromUnit(unit)}`),
77
+ shift: (base, amount, unit) => amount === 0 ? base : appendOperation(base, `${amount < 0 ? "-" : "+"}${Math.abs(amount)}${symbolFromUnit(unit)}`),
73
78
  startOf: (base, unit) => appendOperation(base, `/${symbolFromUnit(unit)}`)
74
79
  }).text;
75
80
  //#endregion
@@ -35,7 +35,7 @@ const NaturalSuggestion = Schema.Struct({
35
35
  var BaseLanguageContribution = class extends Data.TaggedClass("BaseLanguage") {};
36
36
  var LanguageExtensionContribution = class extends Data.TaggedClass("LanguageExtension") {};
37
37
  const canonicalBaseLocale = (input) => Result.getSuccess(Result.try(() => new Intl.Locale(input).baseName));
38
- const Locale = Schema.String.check(Schema.makeFilter((value) => Option.contains(canonicalBaseLocale(value), value), { expected: "a canonical BCP 47 base locale identifier" })).annotate({ identifier: "Locale" });
38
+ const Locale = Schema.String.check(Schema.makeFilter((value) => Option.contains(canonicalBaseLocale(value), value), { expected: "a standard BCP 47 language code" })).annotate({ identifier: "Locale" });
39
39
  const BaseLanguageMetadata = Schema.TaggedStruct("BaseLanguage", {
40
40
  locale: Locale,
41
41
  vocabulary: Schema.Array(Schema.String)
@@ -45,28 +45,34 @@ const compileLanguage = (locale, registered) => {
45
45
  }));
46
46
  };
47
47
  const createRegistry = Effect.fn(function* () {
48
- const entries = yield* Ref.make([]);
48
+ const state = yield* Ref.make({
49
+ entries: [],
50
+ compiledLanguages: /* @__PURE__ */ new Map()
51
+ });
49
52
  const register = Effect.fn(function* (pluginId, contribution) {
50
53
  if (pluginId.length === 0 || !Schema.is(LanguageContributionMetadata)(contribution)) return yield* new LanguageRegistrationError({
51
54
  pluginId,
52
55
  locale: contribution.locale,
53
- message: "Invalid plugin identifier or contribution metadata"
56
+ message: "The plugin name or language settings are invalid"
54
57
  });
55
58
  const token = Symbol(pluginId);
56
- yield* Effect.acquireRelease(Ref.modify(entries, (current) => {
59
+ yield* Effect.acquireRelease(Ref.modify(state, (current) => {
57
60
  const conflictingBase = Match.valueTags(contribution, {
58
- BaseLanguage: (base) => Array.findFirst(current, (entry) => Match.valueTags(entry.contribution, {
61
+ BaseLanguage: (base) => Array.findFirst(current.entries, (entry) => Match.valueTags(entry.contribution, {
59
62
  BaseLanguage: (registeredBase) => registeredBase.locale === base.locale,
60
63
  LanguageExtension: () => false
61
64
  })),
62
65
  LanguageExtension: () => Option.none()
63
66
  });
64
67
  return Option.match(conflictingBase, {
65
- onNone: () => [Result.succeed(token), Array.append(current, {
66
- token,
67
- pluginId,
68
- contribution
69
- })],
68
+ onNone: () => [Result.succeed(token), {
69
+ entries: Array.append(current.entries, {
70
+ token,
71
+ pluginId,
72
+ contribution
73
+ }),
74
+ compiledLanguages: /* @__PURE__ */ new Map()
75
+ }],
70
76
  onSome: (conflict) => [Result.fail(new LanguageConflictError({
71
77
  locale: contribution.locale,
72
78
  firstPluginId: conflict.pluginId,
@@ -74,12 +80,28 @@ const createRegistry = Effect.fn(function* () {
74
80
  message: "Only one base language can be registered for a locale"
75
81
  })), current]
76
82
  });
77
- }).pipe(Effect.flatMap((result) => Effect.fromResult(result))), (registeredToken) => Ref.update(entries, (items) => Array.filter(items, (entry) => entry.token !== registeredToken)));
83
+ }).pipe(Effect.flatMap((result) => Effect.fromResult(result))), (registeredToken) => Ref.update(state, (current) => ({
84
+ entries: Array.filter(current.entries, (entry) => entry.token !== registeredToken),
85
+ compiledLanguages: /* @__PURE__ */ new Map()
86
+ })));
78
87
  });
79
88
  const resolve = Effect.fn(function* (locale) {
89
+ const cached = (yield* Ref.get(state)).compiledLanguages.get(locale);
90
+ if (cached !== void 0) return cached;
80
91
  const canonical = canonicalBaseLocale(locale);
81
92
  if (Option.isSome(canonical)) {
82
- const compiled = compileLanguage(canonical.value, yield* Ref.get(entries));
93
+ const compiled = yield* Ref.modify(state, (current) => {
94
+ const canonicalCached = current.compiledLanguages.get(canonical.value);
95
+ if (canonicalCached !== void 0) return [Option.some(canonicalCached), current];
96
+ const language = compileLanguage(canonical.value, current.entries);
97
+ if (Option.isNone(language)) return [language, current];
98
+ const cache = new Map(current.compiledLanguages);
99
+ cache.set(canonical.value, language.value);
100
+ return [language, {
101
+ entries: current.entries,
102
+ compiledLanguages: cache
103
+ }];
104
+ });
83
105
  if (Option.isSome(compiled)) return compiled.value;
84
106
  }
85
107
  return yield* new UnsupportedLocaleError({ locale });
@@ -102,7 +124,7 @@ const createPluginRegistry = Effect.fn(function* (plugins) {
102
124
  if (duplicate !== void 0) return yield* new LanguageRegistrationError({
103
125
  pluginId: duplicate,
104
126
  locale: "*",
105
- message: "Plugin identifiers must be unique"
127
+ message: "Plugin names must be unique"
106
128
  });
107
129
  const registry = yield* createRegistry();
108
130
  const context = { register: registry.register };