@escape-game-over/atlas 0.1.87 → 0.1.90

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.87",
3
+ "version": "0.1.90",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
package/src/index.ts CHANGED
@@ -215,14 +215,17 @@ export {
215
215
  export {
216
216
  type AudiencePrice,
217
217
  type AudienceTier,
218
+ amountAt,
218
219
  assertAudiences,
219
220
  assertPriceTiers,
220
221
  type CurrencyCode,
222
+ formatPrice,
221
223
  formatQuantities,
222
224
  type Price,
223
225
  type PriceTier,
224
226
  type Quantity,
225
227
  type QuantityRange,
228
+ quantitiesOf,
226
229
  type TieredPrice,
227
230
  tierRange,
228
231
  } from "./money.ts";
@@ -259,8 +262,10 @@ export {
259
262
  enableRoutes,
260
263
  type FamilyMember,
261
264
  type FamilyMemberInput,
265
+ type FamilyMemberSlugs,
262
266
  type FamilyOptions,
263
267
  familyGuard,
268
+ familyMemberSlugs,
264
269
  type RouteIdsOf,
265
270
  } from "./routes/family.ts";
266
271
  export type { RouteEntry } from "./routes/resolve.ts";
package/src/money.ts CHANGED
@@ -480,3 +480,43 @@ export function assertPriceTiers(price: TieredPrice, at: string): void {
480
480
  );
481
481
  }
482
482
  }
483
+
484
+ /**
485
+ * What one unit costs at `quantity`: the amount of the band that covers it, or
486
+ * `undefined` where the table has no band for it — a group too small, too
487
+ * large, or in a hole between bands.
488
+ *
489
+ * The first band that covers it wins. `assertPriceTiers` refuses overlapping
490
+ * bands, so in a table that passed it there is only ever one.
491
+ */
492
+ export function amountAt(
493
+ price: TieredPrice,
494
+ quantity: number
495
+ ): number | undefined {
496
+ for (const tier of price.tiers) {
497
+ const band = tierRange(tier);
498
+ if (quantity >= band.min && quantity <= band.max) {
499
+ return tier.amountPerUnit;
500
+ }
501
+ }
502
+ return undefined;
503
+ }
504
+
505
+ /**
506
+ * A price as a reader in `locale` writes it: the currency's symbol and that
507
+ * language's separators, and no decimals on a whole amount — "£25", not
508
+ * "£25.00", while "€12.50" keeps its cents.
509
+ *
510
+ * Whole amounts drop the decimals because that is how a price list reads; a
511
+ * table of "25.00, 22.00, 20.00" says nothing "25, 22, 20" does not. An amount
512
+ * with cents shows two, so a price is never rounded on the way to the page.
513
+ */
514
+ export function formatPrice(price: Price, locale: string): string {
515
+ const whole = Number.isInteger(price.amount);
516
+ return new Intl.NumberFormat(locale, {
517
+ style: "currency",
518
+ currency: price.currency,
519
+ minimumFractionDigits: whole ? 0 : 2,
520
+ maximumFractionDigits: whole ? 0 : 2,
521
+ }).format(price.amount);
522
+ }
@@ -204,7 +204,12 @@ type InheritedDepthMismatch<
204
204
  > = K extends keyof Base
205
205
  ? Base[K] extends { readonly slugByLocale?: infer M }
206
206
  ? {
207
- [L in keyof M]: L extends OverriddenLocales<T, K>
207
+ // `-?`: the translations are a `Partial`, and a mapped type keeps
208
+ // its `?`, so every entry would read `never | undefined` and the
209
+ // lookup below `undefined` — not `never` — failing every `slug`
210
+ // override of a route whose translations are optional, family
211
+ // members among them.
212
+ [L in keyof M]-?: L extends OverriddenLocales<T, K>
208
213
  ? never
209
214
  : M[L] extends infer V extends string
210
215
  ? SegmentCount<V> extends Depth
@@ -75,6 +75,113 @@ export interface FamilyOptions<L extends string> {
75
75
  readonly sitemap?: RouteSitemap;
76
76
  }
77
77
 
78
+ /** Per-locale translations, keyed by locale. */
79
+ type ByLocale = Readonly<Record<string, string>>;
80
+
81
+ /** One locale's path: its translated halves where it has them, else the defaults. */
82
+ type JoinedSlug<
83
+ Prefix extends string,
84
+ PrefixBy extends ByLocale,
85
+ Segment extends string,
86
+ SegmentBy extends ByLocale,
87
+ K,
88
+ > = `${K extends keyof PrefixBy ? PrefixBy[K] : Prefix}/${K extends keyof SegmentBy
89
+ ? SegmentBy[K]
90
+ : Segment}`;
91
+
92
+ /** What {@link familyMemberSlugs} returns: literal paths, so depth checks still apply. */
93
+ export type FamilyMemberSlugs<
94
+ Prefix extends string,
95
+ Segment extends string,
96
+ PrefixBy extends ByLocale = Record<never, never>,
97
+ SegmentBy extends ByLocale = Record<never, never>,
98
+ > = { readonly slug: `${Prefix}/${Segment}` } & ([
99
+ keyof PrefixBy | keyof SegmentBy,
100
+ ] extends [never]
101
+ ? unknown
102
+ : {
103
+ readonly slugByLocale: {
104
+ readonly [K in keyof PrefixBy | keyof SegmentBy]: JoinedSlug<
105
+ Prefix,
106
+ PrefixBy,
107
+ Segment,
108
+ SegmentBy,
109
+ K
110
+ >;
111
+ };
112
+ });
113
+
114
+ /**
115
+ * A family member's paths, in every locale, as {@link routeFamily} composes
116
+ * them — for a project that gives one member a segment of its own.
117
+ *
118
+ * ```ts
119
+ * overrideRoutes: {
120
+ * "rooms-illuminati": {
121
+ * enabled: true,
122
+ * ...familyMemberSlugs("rooms", "the-da-vinci-code", {
123
+ * prefixByLocale: ROOM_PREFIX_BY_LOCALE,
124
+ * }),
125
+ * },
126
+ * }
127
+ * ```
128
+ *
129
+ * Overriding only `slug` would move the default locale and leave every
130
+ * translated path at the family's id, at the same depth, so nothing would
131
+ * catch it. This restates them all from the same join the family used. Pass
132
+ * the family's `prefixByLocale`, so the member stays under its siblings in
133
+ * every locale.
134
+ */
135
+ export function familyMemberSlugs<
136
+ const Prefix extends string,
137
+ const Segment extends string,
138
+ const PrefixBy extends ByLocale = Record<never, never>,
139
+ const SegmentBy extends ByLocale = Record<never, never>,
140
+ >(
141
+ prefix: Prefix,
142
+ segment: Segment,
143
+ translations: {
144
+ readonly prefixByLocale?: PrefixBy;
145
+ readonly segmentByLocale?: SegmentBy;
146
+ } = {}
147
+ ): FamilyMemberSlugs<Prefix, Segment, PrefixBy, SegmentBy> {
148
+ const slugByLocale = joinByLocale(
149
+ prefix,
150
+ segment,
151
+ translations.prefixByLocale,
152
+ translations.segmentByLocale
153
+ );
154
+ return {
155
+ slug: `${prefix}/${segment}`,
156
+ ...(slugByLocale === undefined ? {} : { slugByLocale }),
157
+ } as never;
158
+ }
159
+
160
+ /**
161
+ * The translated paths of one member, or `undefined` where neither half is
162
+ * translated. A locale gets one when either half is; the untranslated half
163
+ * falls back to the default, so every locale sits at the same depth by
164
+ * construction.
165
+ */
166
+ function joinByLocale(
167
+ prefix: string,
168
+ segment: string,
169
+ prefixByLocale: Readonly<Partial<Record<string, string>>> | undefined,
170
+ segmentByLocale: Readonly<Partial<Record<string, string>>> | undefined
171
+ ): Record<string, string> | undefined {
172
+ const locales = new Set([
173
+ ...Object.keys(prefixByLocale ?? {}),
174
+ ...Object.keys(segmentByLocale ?? {}),
175
+ ]);
176
+ if (locales.size === 0) return undefined;
177
+ const slugByLocale: Record<string, string> = {};
178
+ for (const locale of locales) {
179
+ slugByLocale[locale] =
180
+ `${prefixByLocale?.[locale] ?? prefix}/${segmentByLocale?.[locale] ?? segment}`;
181
+ }
182
+ return slugByLocale;
183
+ }
184
+
78
185
  /**
79
186
  * Expands a family into ordinary route entries, ready to spread into
80
187
  * `defineRoutes`.
@@ -127,26 +234,19 @@ export function routeFamily<
127
234
  const segmentByLocale =
128
235
  typeof member === "string" ? undefined : member.segmentByLocale;
129
236
 
130
- // A locale gets a translated slug when either half is translated; the
131
- // untranslated half falls back to what the default slug uses, so the
132
- // number of segments is the same in every locale by construction.
133
- const locales = new Set([
134
- ...Object.keys(options?.prefixByLocale ?? {}),
135
- ...Object.keys(segmentByLocale ?? {}),
136
- ]);
137
- const slugByLocale: Record<string, string> = {};
138
- for (const locale of locales) {
139
- const localePrefix =
140
- options?.prefixByLocale?.[locale as LocalesOf<C>] ?? prefix;
141
- const localeSegment =
142
- segmentByLocale?.[locale as LocalesOf<C>] ?? id;
143
- slugByLocale[locale] = `${localePrefix}/${localeSegment}`;
144
- }
237
+ // The same join `familyMemberSlugs` gives a project, so a member it
238
+ // moves sits exactly where the family would have put it.
239
+ const slugByLocale = joinByLocale(
240
+ prefix,
241
+ id,
242
+ options?.prefixByLocale,
243
+ segmentByLocale
244
+ );
145
245
 
146
246
  routes[`${prefix.replaceAll("/", "-")}-${id}`] = {
147
247
  slug: `${prefix}/${id}`,
148
248
  enabled: options?.enabled ?? true,
149
- ...(locales.size > 0
249
+ ...(slugByLocale !== undefined
150
250
  ? {
151
251
  slugByLocale: slugByLocale as Readonly<
152
252
  Partial<Record<LocalesOf<C>, string>>