lambder 14.1.1 → 15.0.2

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 (158) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +14 -17
  3. package/dist/api/LambderApiDeclarations.d.ts +11 -5
  4. package/dist/api/LambderApiDefinition.d.ts +2 -1
  5. package/dist/api/LambderApiEnvelope.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.js +8 -2
  7. package/dist/api/LambderApiGuards.d.ts +44 -5
  8. package/dist/api/LambderApiGuards.js +27 -0
  9. package/dist/api/LambderApiIdempotency.d.ts +14 -7
  10. package/dist/api/LambderApiIdempotency.js +39 -27
  11. package/dist/api/LambderApiPipeline.d.ts +25 -11
  12. package/dist/api/LambderApiPipeline.js +34 -17
  13. package/dist/api/LambderApiRateLimits.d.ts +28 -21
  14. package/dist/api/LambderApiRateLimits.js +55 -35
  15. package/dist/api/LambderApiRefusals.d.ts +10 -4
  16. package/dist/api/LambderApiRefusals.js +31 -5
  17. package/dist/api/LambderApiRequest.d.ts +21 -1
  18. package/dist/api/LambderApiRequest.js +31 -2
  19. package/dist/build/checkApiRefusals.d.ts +8 -3
  20. package/dist/build/checkApiRefusals.js +80 -29
  21. package/dist/build/contractPrinterProcess.d.ts +11 -0
  22. package/dist/build/contractPrinterProcess.js +17 -0
  23. package/dist/build/generateApiFiles.d.ts +26 -5
  24. package/dist/build/generateApiFiles.js +68 -7
  25. package/dist/build/writeApiSchemas.d.ts +109 -0
  26. package/dist/build/writeApiSchemas.js +308 -0
  27. package/dist/build.d.ts +3 -1
  28. package/dist/build.js +2 -0
  29. package/dist/client/LambderCaller.d.ts +25 -11
  30. package/dist/client/LambderCaller.js +57 -49
  31. package/dist/client/LambderUploadRunner.d.ts +55 -27
  32. package/dist/client/LambderUploadRunner.js +89 -34
  33. package/dist/client.d.ts +6 -6
  34. package/dist/client.js +2 -2
  35. package/dist/core/Lambder.d.ts +31 -21
  36. package/dist/core/Lambder.js +79 -23
  37. package/dist/core/LambderCallSummary.d.ts +1 -1
  38. package/dist/core/LambderContext.d.ts +59 -7
  39. package/dist/core/LambderContext.js +31 -15
  40. package/dist/core/LambderCrashHandling.d.ts +5 -0
  41. package/dist/core/LambderCrashHandling.js +11 -1
  42. package/dist/core/LambderCreateOptions.d.ts +28 -17
  43. package/dist/core/LambderCreateOptions.js +14 -7
  44. package/dist/core/LambderResolver.d.ts +3 -2
  45. package/dist/core/LambderResponseBuilder.d.ts +20 -9
  46. package/dist/core/LambderResponseBuilder.js +39 -8
  47. package/dist/core/LambderTemplatingEngine.d.ts +24 -6
  48. package/dist/core/LambderTemplatingEngine.js +23 -3
  49. package/dist/index.d.ts +15 -12
  50. package/dist/index.js +7 -5
  51. package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
  52. package/dist/invoke/LambderInvokeCaller.js +47 -9
  53. package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
  54. package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
  55. package/dist/mock/LambderMockAdapterDoor.js +10 -0
  56. package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
  57. package/dist/mock/LambderMockApiSchemas.js +150 -0
  58. package/dist/mock/LambderMockApp.d.ts +56 -48
  59. package/dist/mock/LambderMockApp.js +107 -62
  60. package/dist/mock/LambderMockBrowserCookies.js +18 -5
  61. package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
  62. package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
  63. package/dist/mock/LambderMockFailureInjector.js +12 -8
  64. package/dist/mock/LambderMockTypes.d.ts +8 -4
  65. package/dist/mock/lambderMockInvokeTransport.js +2 -9
  66. package/dist/mock/lambderMockMswHandler.d.ts +19 -13
  67. package/dist/mock/lambderMockMswHandler.js +25 -22
  68. package/dist/mock.d.ts +1 -0
  69. package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
  70. package/dist/secrets/LambderOneShotSecrets.js +6 -36
  71. package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
  72. package/dist/secrets/LambderPasswordHasher.js +168 -0
  73. package/dist/session/LambderSessionController.d.ts +37 -4
  74. package/dist/session/LambderSessionController.js +36 -6
  75. package/dist/session/LambderSessionManager.d.ts +33 -3
  76. package/dist/session/LambderSessionManager.js +55 -16
  77. package/dist/shared/LambderHtml.d.ts +23 -4
  78. package/dist/shared/LambderHtml.js +17 -5
  79. package/dist/shared/LambderI18n.d.ts +106 -16
  80. package/dist/shared/LambderI18n.js +259 -74
  81. package/dist/shared/contracts/LambderCache.d.ts +14 -1
  82. package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
  83. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  84. package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
  85. package/dist/shared/util/LambderBackoffTimer.js +40 -12
  86. package/dist/shared/util/LambderCallAbort.d.ts +22 -6
  87. package/dist/shared/util/LambderCallAbort.js +37 -9
  88. package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
  89. package/dist/shared/util/LambderErrorReportMark.js +22 -0
  90. package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
  91. package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
  92. package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
  93. package/dist/shared/util/LambderSignedClaims.js +54 -0
  94. package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
  95. package/dist/shared/util/LambderSwappableInstances.js +54 -0
  96. package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
  97. package/dist/shared/util/LambderTestingDoors.js +6 -2
  98. package/dist/shared/util/LambderTextDigest.d.ts +5 -5
  99. package/dist/shared/util/LambderTextDigest.js +5 -5
  100. package/dist/shared/wire/LambderApiContract.d.ts +1 -1
  101. package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
  102. package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
  103. package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
  104. package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
  105. package/dist/shared/wire/LambderApiOutcome.js +1 -1
  106. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
  107. package/dist/shared/wire/LambderApiRefusal.js +3 -3
  108. package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
  109. package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
  110. package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
  111. package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
  112. package/dist/shared/wire/LambderCallOutcome.js +3 -2
  113. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
  114. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
  115. package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
  116. package/dist/shared/wire/LambderInvokeApiId.js +3 -3
  117. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
  118. package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
  119. package/dist/stores/LambderCacheValues.d.ts +2 -5
  120. package/dist/stores/LambderDdbCache.d.ts +15 -9
  121. package/dist/stores/LambderDdbCache.js +25 -6
  122. package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
  123. package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
  124. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
  125. package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
  126. package/dist/stores/LambderDdbSdk.d.ts +59 -29
  127. package/dist/stores/LambderDdbSdk.js +116 -44
  128. package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
  129. package/dist/stores/LambderDdbSessionStore.js +133 -68
  130. package/dist/stores/LambderMemoryCache.d.ts +2 -2
  131. package/dist/stores/LambderS3FileSource.d.ts +3 -2
  132. package/dist/stores/LambderS3FileSource.js +11 -13
  133. package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
  134. package/dist/stores/LambderS3UploadBucket.js +23 -0
  135. package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
  136. package/dist/stores/LambderStoredText.d.ts +26 -0
  137. package/dist/stores/LambderStoredText.js +25 -0
  138. package/dist/testing/LambderConformanceRunner.d.ts +9 -2
  139. package/dist/testing/LambderConformanceRunner.js +9 -2
  140. package/dist/testing/LambderTestApp.d.ts +65 -7
  141. package/dist/testing/LambderTestApp.js +101 -6
  142. package/dist/testing/LambderTestVisitor.d.ts +43 -12
  143. package/dist/testing/LambderTestVisitor.js +35 -11
  144. package/dist/testing/bootLambdaPackage.d.ts +105 -0
  145. package/dist/testing/bootLambdaPackage.js +265 -0
  146. package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
  147. package/dist/testing/lambdaPackageBootProcess.js +130 -0
  148. package/dist/testing/lambderCacheConformance.d.ts +26 -0
  149. package/dist/testing/lambderCacheConformance.js +287 -0
  150. package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
  151. package/dist/testing/lambderCacheStorageConformance.js +159 -0
  152. package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
  153. package/dist/testing/lambderSessionStoreConformance.js +3 -3
  154. package/dist/testing.d.ts +7 -1
  155. package/dist/testing.js +5 -0
  156. package/package.json +1 -6
  157. package/dist/shared/util/boundKeyField.d.ts +0 -20
  158. package/dist/shared/util/boundKeyField.js +0 -34
@@ -171,9 +171,15 @@ export default class LambderSessionManager<SessionData = any> {
171
171
  */
172
172
  private hashToken;
173
173
  createSession(sessionKey: string, data?: SessionData, ttlInSeconds?: number, options?: {
174
- /** The data refresh deadline to start with (regenerateSession starts its data due). */
174
+ /** The data refresh deadline to start with, in epoch seconds. Default: dataRefresh.ttlSeconds from now. */
175
175
  dataExpiresAt?: number;
176
176
  }): Promise<LambderCreatedSession<SessionData>>;
177
+ /**
178
+ * Writes a new record holding `data` under fresh tokens, and hands it
179
+ * back with `served` as its data: the schema's reading of what the record
180
+ * holds, which createSession has checked and a rotation carries over.
181
+ */
182
+ private mintSession;
177
183
  /**
178
184
  * Writes new session data onto the record, and nothing else: the expiry
179
185
  * slides only in renewSession, which also re-issues the cookies, so a
@@ -216,8 +222,16 @@ export default class LambderSessionManager<SessionData = any> {
216
222
  * when the session is over: a refresh said so (a deleted or disabled
217
223
  * login), or the record was deleted while this request read it (a
218
224
  * logout, a password change), which a renewal must not undo.
225
+ *
226
+ * `refreshData: true` renews the data on this read whatever its
227
+ * deadline, for a read that wants it fresh (a page load): the callback
228
+ * runs once and its result shares the read's one write, where a read
229
+ * followed by refreshSessionData would run both twice whenever the data
230
+ * was due. Requires dataRefresh, and throws without it.
219
231
  */
220
- renewSession(session: LambderSessionRecord<SessionData>): Promise<LambderSessionRecord<SessionData> | null>;
232
+ renewSession(session: LambderSessionRecord<SessionData>, options?: {
233
+ refreshData?: boolean;
234
+ }): Promise<LambderSessionRecord<SessionData> | null>;
221
235
  /**
222
236
  * Data about to be stored, held to the schema: what a read of it will
223
237
  * serve, or the error `refused` builds from where it does not match.
@@ -297,7 +311,7 @@ export default class LambderSessionManager<SessionData = any> {
297
311
  * than landing data derived before the change. Requires dataRefresh to
298
312
  * be configured.
299
313
  */
300
- expireSessionDataAllByKey(sessionKey: string): Promise<boolean>;
314
+ expireSessionDataAllByKey(sessionKey: string): Promise<void>;
301
315
  /**
302
316
  * Replaces the session with a new one under new tokens, carrying over
303
317
  * the data as the delete removed it rather than as this request read
@@ -319,6 +333,22 @@ export default class LambderSessionManager<SessionData = any> {
319
333
  * marked by expireSessionDataAllByKey while the new record was being
320
334
  * written may have passed it by, and due, its next read renews the data
321
335
  * from the source of truth either way.
336
+ *
337
+ * The record is read first, because what the request holds is the
338
+ * schema's reading of the data, which a schema that strips keys or
339
+ * transforms is not the data the record holds. The new record holds the
340
+ * stored data, every comparison is between stored data, and the request
341
+ * is handed the schema's reading of what the new record holds, as a read
342
+ * would hand it.
322
343
  */
323
344
  regenerateSession(session: LambderSessionRecord<SessionData>): Promise<LambderCreatedSession<SessionData> | null>;
345
+ /**
346
+ * Data a rotation carries over, as the request is handed it: the
347
+ * schema's reading of it. It is the record's own data rather than the
348
+ * app's, so it is not refused as a write would be. Where the schema
349
+ * refuses it, the request keeps the data it already held, and the new
350
+ * record's next read meets it as every read meets such data: refreshed
351
+ * with dataRefresh (the new record starts due), ended without.
352
+ */
353
+ private carriedDataOf;
324
354
  }
@@ -185,6 +185,14 @@ export default class LambderSessionManager {
185
185
  // Refused for the same reason: data the schema refuses would end the
186
186
  // session on its first read, a sign-out right after the sign-in.
187
187
  const served = this.servedDataOf(data, (issues) => new Error(`Lambder: createSession was given data that does not match session.dataSchema (${issues}).`));
188
+ return await this.mintSession(sessionKey, data, served, ttlInSeconds, options?.dataExpiresAt);
189
+ }
190
+ /**
191
+ * Writes a new record holding `data` under fresh tokens, and hands it
192
+ * back with `served` as its data: the schema's reading of what the record
193
+ * holds, which createSession has checked and a rotation carries over.
194
+ */
195
+ async mintSession(sessionKey, data, served, ttlInSeconds, dataExpiresAt) {
188
196
  const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
189
197
  // The sort-key SECRET goes to the client; only its hash becomes the
190
198
  // store's range key, so the store never contains a usable token.
@@ -200,7 +208,7 @@ export default class LambderSessionManager {
200
208
  csrfTokenHash: await this.hashToken(csrfToken),
201
209
  sessionKey, data,
202
210
  createdAt, lastAccessedAt, expiresAt, ttlInSeconds,
203
- ...(this.dataRefresh ? { dataExpiresAt: options?.dataExpiresAt ?? (createdAt + this.dataRefresh.ttlSeconds) } : {}),
211
+ ...(this.dataRefresh ? { dataExpiresAt: dataExpiresAt ?? (createdAt + this.dataRefresh.ttlSeconds) } : {}),
204
212
  dataVersion: 0,
205
213
  };
206
214
  await this.store.create(session);
@@ -320,8 +328,16 @@ export default class LambderSessionManager {
320
328
  * when the session is over: a refresh said so (a deleted or disabled
321
329
  * login), or the record was deleted while this request read it (a
322
330
  * logout, a password change), which a renewal must not undo.
331
+ *
332
+ * `refreshData: true` renews the data on this read whatever its
333
+ * deadline, for a read that wants it fresh (a page load): the callback
334
+ * runs once and its result shares the read's one write, where a read
335
+ * followed by refreshSessionData would run both twice whenever the data
336
+ * was due. Requires dataRefresh, and throws without it.
323
337
  */
324
- async renewSession(session) {
338
+ async renewSession(session, options = {}) {
339
+ if (options.refreshData && !this.dataRefresh)
340
+ throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
325
341
  const now = Math.floor(Date.now() / 1000);
326
342
  const refreshed = {};
327
343
  const slid = {};
@@ -337,10 +353,10 @@ export default class LambderSessionManager {
337
353
  // once a refresh ran, the refresh's.
338
354
  let served = read?.success ? read.data : session.data;
339
355
  // Renew session.data once its shelf life has passed (opt-in
340
- // dataRefresh), or at once when it does not match the schema. A
341
- // record created while dataRefresh was off has no dataExpiresAt, so
342
- // it renews on its first read.
343
- if (this.dataRefresh && (read?.success === false || (session.dataExpiresAt ?? 0) <= now)) {
356
+ // dataRefresh), at once when it does not match the schema, or when
357
+ // the read asks for it. A record created while dataRefresh was off
358
+ // has no dataExpiresAt, so it renews on its first read.
359
+ if (this.dataRefresh && (options.refreshData === true || read?.success === false || (session.dataExpiresAt ?? 0) <= now)) {
344
360
  let newData;
345
361
  try {
346
362
  newData = await this.dataRefresh.refresh(session);
@@ -573,7 +589,6 @@ export default class LambderSessionManager {
573
589
  const now = Math.floor(Date.now() / 1000);
574
590
  // An update, so a session deleted in between stays deleted.
575
591
  await this.forEachSessionOf(sessionKeyHash, (secretHash) => this.store.update(sessionKeyHash, secretHash, { dataExpiresAt: now }));
576
- return true;
577
592
  }
578
593
  ;
579
594
  /**
@@ -597,29 +612,53 @@ export default class LambderSessionManager {
597
612
  * marked by expireSessionDataAllByKey while the new record was being
598
613
  * written may have passed it by, and due, its next read renews the data
599
614
  * from the source of truth either way.
615
+ *
616
+ * The record is read first, because what the request holds is the
617
+ * schema's reading of the data, which a schema that strips keys or
618
+ * transforms is not the data the record holds. The new record holds the
619
+ * stored data, every comparison is between stored data, and the request
620
+ * is handed the schema's reading of what the new record holds, as a read
621
+ * would hand it.
600
622
  */
601
623
  async regenerateSession(session) {
602
624
  if (!session)
603
625
  throw new Error("Invalid session");
604
626
  const now = Math.floor(Date.now() / 1000);
605
- const created = await this.createSession(session.sessionKey, session.data, session.ttlInSeconds, this.dataRefresh ? { dataExpiresAt: now } : undefined);
627
+ const stored = await this.store.get(session.sessionKeyHash, session.secretHash);
628
+ if (!stored || stored.expiresAt <= now)
629
+ return null;
630
+ const created = await this.mintSession(session.sessionKey, stored.data, this.carriedDataOf(stored.data, session), session.ttlInSeconds, this.dataRefresh ? now : undefined);
606
631
  const replacement = created.session;
607
632
  const removed = await this.store.delete(session.sessionKeyHash, session.secretHash);
608
633
  if (!removed || removed.expiresAt <= now) {
609
634
  await this.store.delete(replacement.sessionKeyHash, replacement.secretHash);
610
635
  return null;
611
636
  }
612
- if (canonicalJson(removed.data) === canonicalJson(session.data))
637
+ if (canonicalJson(removed.data) === canonicalJson(stored.data))
613
638
  return created;
614
- // Another request wrote the data after this one read it. The write
615
- // moves the replacement's dataVersion one past the one it was created
616
- // with, and the record handed back says so: a data write later in
617
- // this request names the version the store holds rather than answer
618
- // stale. A mark landing in between still makes it stale, the safe
619
- // side (see updateSessionData).
639
+ // Another request wrote the data between this read and the delete.
640
+ // The write moves the replacement's dataVersion one past the one it
641
+ // was created with, and the record handed back says so: a data write
642
+ // later in this request names the version the store holds rather
643
+ // than answer stale. A mark landing in between still makes it stale,
644
+ // the safe side (see updateSessionData).
620
645
  if (await this.store.update(replacement.sessionKeyHash, replacement.secretHash, { data: removed.data }) === "missing")
621
646
  return null;
622
- return { ...created, session: { ...replacement, data: removed.data, dataVersion: replacement.dataVersion + 1 } };
647
+ return { ...created, session: { ...replacement, data: this.carriedDataOf(removed.data, session), dataVersion: replacement.dataVersion + 1 } };
648
+ }
649
+ /**
650
+ * Data a rotation carries over, as the request is handed it: the
651
+ * schema's reading of it. It is the record's own data rather than the
652
+ * app's, so it is not refused as a write would be. Where the schema
653
+ * refuses it, the request keeps the data it already held, and the new
654
+ * record's next read meets it as every read meets such data: refreshed
655
+ * with dataRefresh (the new record starts due), ended without.
656
+ */
657
+ carriedDataOf(data, session) {
658
+ if (!this.dataSchema)
659
+ return data;
660
+ const read = this.dataSchema.safeParse(data);
661
+ return read.success ? read.data : session.data;
623
662
  }
624
663
  }
625
664
  ;
@@ -65,8 +65,27 @@ export declare const xml: (strings: TemplateStringsArray, ...values: LambderHtml
65
65
  /** Mark a trusted string as safe (inserted without escaping). Never pass user input. */
66
66
  export declare const raw: (value: string) => LambderSafeHtml;
67
67
  /**
68
- * Server-preloaded state as <script type="application/json" id="..."> so an SPA
69
- * can hydrate without a first fetch. Escaped so the payload can't break out of
70
- * the script element. Read with JSON.parse(document.getElementById(id).textContent).
68
+ * Where jsonScript writes its data, and the id it carries. A data block
69
+ * (`application/json`, the default) is found by its id, so it needs one;
70
+ * JSON-LD (`application/ld+json`) is read by whatever crawls the page, so its
71
+ * id is optional.
71
72
  */
72
- export declare const jsonScript: (id: string, data: unknown) => LambderSafeHtml;
73
+ export type LambderJsonScriptOptions = {
74
+ type?: "application/json";
75
+ id: string;
76
+ } | {
77
+ type: "application/ld+json";
78
+ id?: string;
79
+ };
80
+ /**
81
+ * JSON in a <script> element, escaped so the payload cannot break out of it:
82
+ * every `<` is written `\u003c` (so no `</script>` or `<!--` can appear), and
83
+ * U+2028 and U+2029 as escapes too, which JSON.parse reads back unchanged.
84
+ *
85
+ * `jsonScript(id, data)` is server-preloaded state as
86
+ * <script type="application/json" id="...">, so an SPA can hydrate without a
87
+ * first fetch; read it with JSON.parse(document.getElementById(id).textContent).
88
+ * `jsonScript({ type: "application/ld+json" }, data)` is structured data for
89
+ * search engines, with the same escaping.
90
+ */
91
+ export declare const jsonScript: (target: string | LambderJsonScriptOptions, data: unknown) => LambderSafeHtml;
@@ -169,14 +169,26 @@ export const xml = html;
169
169
  /** Mark a trusted string as safe (inserted without escaping). Never pass user input. */
170
170
  export const raw = (value) => new LambderSafeHtml(value);
171
171
  /**
172
- * Server-preloaded state as <script type="application/json" id="..."> so an SPA
173
- * can hydrate without a first fetch. Escaped so the payload can't break out of
174
- * the script element. Read with JSON.parse(document.getElementById(id).textContent).
172
+ * JSON in a <script> element, escaped so the payload cannot break out of it:
173
+ * every `<` is written `\u003c` (so no `</script>` or `<!--` can appear), and
174
+ * U+2028 and U+2029 as escapes too, which JSON.parse reads back unchanged.
175
+ *
176
+ * `jsonScript(id, data)` is server-preloaded state as
177
+ * <script type="application/json" id="...">, so an SPA can hydrate without a
178
+ * first fetch; read it with JSON.parse(document.getElementById(id).textContent).
179
+ * `jsonScript({ type: "application/ld+json" }, data)` is structured data for
180
+ * search engines, with the same escaping.
175
181
  */
176
- export const jsonScript = (id, data) => {
182
+ export const jsonScript = (target, data) => {
183
+ const { type = "application/json", id } = typeof target === "string" ? { id: target } : target;
184
+ // The type is written into the markup as it is, so only the two it may be get there.
185
+ if (type !== "application/json" && type !== "application/ld+json") {
186
+ throw new Error(`jsonScript: type must be "application/json" or "application/ld+json", got ${JSON.stringify(type)}.`);
187
+ }
177
188
  const json = JSON.stringify(data)
178
189
  .replace(/</g, "\\u003c")
179
190
  .replace(/\u2028/g, "\\u2028")
180
191
  .replace(/\u2029/g, "\\u2029");
181
- return new LambderSafeHtml(`<script type="application/json" id="${escapeHtml(id)}">${json}</script>`);
192
+ const idAttribute = id === undefined ? "" : ` id="${escapeHtml(id)}"`;
193
+ return new LambderSafeHtml(`<script type="${type}"${idAttribute}>${json}</script>`);
182
194
  };
@@ -18,13 +18,63 @@ export interface LambderLanguageMeta {
18
18
  /** App-specific extras (e.g. flag emoji). */
19
19
  [extra: string]: unknown;
20
20
  }
21
+ /**
22
+ * The plural categories `Intl.PluralRules` sorts a count into. Which of them
23
+ * a language uses is its own: English uses one and other, Arabic all six,
24
+ * Japanese other alone.
25
+ */
26
+ export type LambderI18nPluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
27
+ /**
28
+ * A text that varies with a count: one form per plural category, keyed by
29
+ * category, so `{ one: "{count} item", other: "{count} items" }`.
30
+ * `t(key, { count })` picks the form for the count under the plural rules
31
+ * of the language the text is in, and `other` when the entry lacks the
32
+ * category picked. `other` is required, so there is always a form to fall
33
+ * back to. Which other categories a language uses is the runtime's plural
34
+ * data, which differs between runtimes, so whether an entry holds them all
35
+ * is what `checkPluralCoverage()` reports, in a test or a build, rather than
36
+ * anything that fails where the dictionary arrives.
37
+ */
38
+ export interface LambderI18nPluralEntry {
39
+ zero?: string;
40
+ one?: string;
41
+ two?: string;
42
+ few?: string;
43
+ many?: string;
44
+ other: string;
45
+ }
46
+ /** What a dictionary holds under one key: a text, or a plural entry. */
47
+ export type LambderI18nDictionaryEntry = string | LambderI18nPluralEntry;
21
48
  /** Extracts `{param}` placeholder names from a string literal type. */
22
49
  export type LambderI18nExtractParams<S extends string> = S extends `${string}{${infer P}}${infer Rest}` ? P | LambderI18nExtractParams<Rest> : never;
50
+ /** The `{param}` names across every form of a plural entry. */
51
+ type PluralEntryParams<TEntry> = LambderI18nExtractParams<Extract<TEntry[keyof TEntry], string>>;
23
52
  /**
24
53
  * Typed translator: `t(key)`, and when the key's contract value contains
25
- * `{tokens}`, a params object with exactly those tokens is required.
54
+ * `{tokens}`, a params object with exactly those tokens is required. A
55
+ * plural key always takes one: a numeric `count`, which picks the form,
56
+ * beside the tokens across its forms.
57
+ *
58
+ * The text test is wrapped (`[...] extends [string]`) so that it does not
59
+ * distribute: a translator stays comparable to `(key: string) => string`,
60
+ * which a cast for keys known only at runtime relies on.
61
+ */
62
+ export type LambderI18nTranslator<TContract extends Record<string, LambderI18nDictionaryEntry>> = <K extends keyof TContract & string>(...args: [TContract[K]] extends [string] ? LambderI18nExtractParams<TContract[K] & string> extends never ? [key: K] : [key: K, params: Record<LambderI18nExtractParams<TContract[K] & string>, string | number>] : [key: K, params: {
63
+ count: number;
64
+ } & Record<Exclude<PluralEntryParams<TContract[K]>, "count">, string | number>]) => string;
65
+ /**
66
+ * What a language other than the default gives for a contract (the default
67
+ * language's block): a text for each text, and for each plural entry a
68
+ * plural entry of its own, whose forms are its language's. A plain text in
69
+ * place of a plural entry stands for the other form alone: complete for a
70
+ * language whose rules use nothing else (Japanese, say), and a gap that
71
+ * `checkPluralCoverage()` reports for any other. A plural entry where the
72
+ * contract has a text is a type error: `t` takes no count for that key, so
73
+ * no form could be picked.
26
74
  */
27
- export type LambderI18nTranslator<TContract extends Record<string, string>> = <K extends keyof TContract & string>(...args: LambderI18nExtractParams<TContract[K]> extends never ? [key: K] : [key: K, params: Record<LambderI18nExtractParams<TContract[K]>, string | number>]) => string;
75
+ type TranslationBlock<TContract> = {
76
+ [K in keyof TContract]: TContract[K] extends string ? string : LambderI18nDictionaryEntry;
77
+ };
28
78
  /**
29
79
  * A language block fetched on demand instead of bundled: a function that
30
80
  * resolves to the dictionary, or to a module whose default export is the
@@ -40,7 +90,7 @@ export type LambderI18nDictionaryLoader<TDict> = () => Promise<TDict | {
40
90
  * rather than the union, is what lets a compile error name the missing key.
41
91
  */
42
92
  type LanguageBlockFor<TBlocks, L, TDict> = L extends keyof TBlocks ? TBlocks[L] extends (...args: never[]) => unknown ? LambderI18nDictionaryLoader<TDict> : TDict : TDict;
43
- export interface LambderI18nConfig<TLanguages extends Record<string, LambderLanguageMeta>, TDefault extends keyof TLanguages & string, TEnforced extends readonly (keyof TLanguages & string)[], TBase extends Record<TDefault, Record<string, string>>> {
93
+ export interface LambderI18nConfig<TLanguages extends Record<string, LambderLanguageMeta>, TDefault extends keyof TLanguages & string, TEnforced extends readonly (keyof TLanguages & string)[], TBase extends Record<TDefault, Record<string, LambderI18nDictionaryEntry>>> {
44
94
  /** Master registry of every supported language and its metadata. */
45
95
  languages: TLanguages;
46
96
  /** Final fallback language. Must be included in `enforced`. */
@@ -53,16 +103,18 @@ export interface LambderI18nConfig<TLanguages extends Record<string, LambderLang
53
103
  /**
54
104
  * App-wide base dictionary. Strict: every language in `languages` must
55
105
  * provide every key (the `defaultLanguage` block is the typed contract).
56
- * Any language but the default may be a loader instead
106
+ * A key's entry is a text, or a plural entry where the text varies with
107
+ * a count. Any language but the default may be a loader instead
57
108
  * (`tr: () => import("./tr")`), fetched by `loadLanguage`. The default
58
109
  * block stays inline, because every lookup falls back to it.
59
110
  */
60
111
  base: TBase & {
61
- [L in keyof TLanguages]: L extends TDefault ? Record<keyof TBase[TDefault], string> : LanguageBlockFor<TBase, L, Record<keyof TBase[TDefault], string>>;
112
+ [L in keyof TLanguages]: L extends TDefault ? Record<keyof TBase[TDefault], LambderI18nDictionaryEntry> : LanguageBlockFor<TBase, L, TranslationBlock<TBase[TDefault]>>;
62
113
  };
63
114
  /**
64
115
  * Optional language detector, tried before browser detection. Return a
65
- * supported code to pick it, or null/undefined to continue the chain:
116
+ * supported code, in any case, to pick it (it is matched as language tags
117
+ * are, case-insensitively), or anything else to continue the chain:
66
118
  * setLanguage override → detectLanguage → browser languages → defaultLanguage.
67
119
  */
68
120
  detectLanguage?: (helpers: {
@@ -71,7 +123,7 @@ export interface LambderI18nConfig<TLanguages extends Record<string, LambderLang
71
123
  defaultLanguage: TDefault;
72
124
  }) => string | null | undefined;
73
125
  }
74
- export interface LambderI18nInstance<TLanguages extends Record<string, LambderLanguageMeta>, TDefault extends keyof TLanguages & string, TEnforced extends readonly (keyof TLanguages & string)[], TContract extends Record<string, string>> {
126
+ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLanguageMeta>, TDefault extends keyof TLanguages & string, TEnforced extends readonly (keyof TLanguages & string)[], TContract extends Record<string, LambderI18nDictionaryEntry>> {
75
127
  /** Translate using the automatically resolved active language. */
76
128
  t: LambderI18nTranslator<TContract>;
77
129
  /** Translator bound to an explicit language (per-request backend use). */
@@ -83,9 +135,9 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
83
135
  * Returns a new instance whose key space = parent keys + new keys.
84
136
  */
85
137
  extend<const TExt extends {
86
- [D in TDefault]: Record<string, string>;
138
+ [D in TDefault]: Record<string, LambderI18nDictionaryEntry>;
87
139
  }>(dict: {
88
- [L in keyof TLanguages]: L extends TDefault ? Record<keyof TExt[TDefault], string> : LanguageBlockFor<TExt, L, Record<keyof TExt[TDefault], string>>;
140
+ [L in keyof TLanguages]: L extends TDefault ? Record<keyof TExt[TDefault], LambderI18nDictionaryEntry> : LanguageBlockFor<TExt, L, TranslationBlock<TExt[TDefault]>>;
89
141
  } & {
90
142
  [D in TDefault]: Partial<Record<keyof TContract, never>>;
91
143
  } & TExt): LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract & TExt[TDefault]>;
@@ -97,11 +149,11 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
97
149
  * Any language but the default may be a loader, as in `base`.
98
150
  */
99
151
  extendPartial<const TExt extends {
100
- [D in TDefault]: Record<string, string>;
152
+ [D in TDefault]: Record<string, LambderI18nDictionaryEntry>;
101
153
  }>(dict: {
102
- [E in TEnforced[number]]: E extends TDefault ? Record<keyof TExt[TDefault], string> : LanguageBlockFor<TExt, E, Record<keyof TExt[TDefault], string>>;
154
+ [E in TEnforced[number]]: E extends TDefault ? Record<keyof TExt[TDefault], LambderI18nDictionaryEntry> : LanguageBlockFor<TExt, E, TranslationBlock<TExt[TDefault]>>;
103
155
  } & {
104
- [L in Exclude<keyof TLanguages & string, TEnforced[number]>]?: LanguageBlockFor<TExt, L, Partial<Record<keyof TExt[TDefault], string>>>;
156
+ [L in Exclude<keyof TLanguages & string, TEnforced[number]>]?: LanguageBlockFor<TExt, L, Partial<TranslationBlock<TExt[TDefault]>>>;
105
157
  } & {
106
158
  [D in TDefault]: Partial<Record<keyof TContract, never>>;
107
159
  } & TExt): LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract & TExt[TDefault]>;
@@ -118,8 +170,27 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
118
170
  * `loadLanguage()`, which runs only what is still missing.
119
171
  */
120
172
  loadLanguage(code?: keyof TLanguages & string): Promise<void>;
121
- /** Merge additional translations at runtime (e.g. fetched from an API). Notifies change listeners. */
122
- registerDictionary(code: keyof TLanguages & string, dict: Record<string, string>): void;
173
+ /**
174
+ * Merge additional translations at runtime (e.g. fetched from an API),
175
+ * checked as inline blocks are. Notifies change listeners.
176
+ */
177
+ registerDictionary(code: keyof TLanguages & string, dict: Record<string, LambderI18nDictionaryEntry>): void;
178
+ /**
179
+ * Whether every plural entry this instance translates with (its own and
180
+ * its parents', in every language) holds a form for each category the
181
+ * language's plural rules use, by this runtime's Intl plural data. Loads
182
+ * every language first, as `loadLanguage` does for each, so entries behind
183
+ * loaders are checked too. Resolves when nothing is missing; rejects with
184
+ * every gap listed by key, language and missing categories, or with the
185
+ * failure of a loader that would not load.
186
+ *
187
+ * For a test or a build step, not for app start: runtimes ship different
188
+ * plural data, and `t` falls back to an entry's `other` form for a
189
+ * category it lacks, so a gap shows the other form rather than failing.
190
+ * An extension sees its parents but not its siblings, so check each leaf
191
+ * extension.
192
+ */
193
+ checkPluralCoverage(): Promise<void>;
123
194
  /**
124
195
  * Override the active language (shared with all extended instances), and
125
196
  * start its loaders; change listeners fire again when they land. Await
@@ -150,7 +221,11 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
150
221
  * `i18n.onLanguageChange(() => i18n.applyToDocument())`.
151
222
  */
152
223
  applyToDocument(): void;
153
- /** Type guard: is this string a supported language code? */
224
+ /**
225
+ * Type guard: is this string a supported language code, spelled exactly
226
+ * as registered? The methods that take a code match it in any case, as
227
+ * language tags are compared, and answer it as registered.
228
+ */
154
229
  isLanguageCode(value: string): value is keyof TLanguages & string;
155
230
  readonly languages: TLanguages;
156
231
  readonly languageList: (keyof TLanguages & string)[];
@@ -161,6 +236,21 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
161
236
  readonly defaultLanguage: TDefault;
162
237
  readonly enforced: TEnforced;
163
238
  }
239
+ /**
240
+ * What reading translations takes, over any instance of one contract: `t`,
241
+ * `forLanguage`, the active language and its metadata, change notifications
242
+ * and the registry, without the members that change what an instance
243
+ * answers (`setLanguage`, `resetLanguage`, `loadLanguage`,
244
+ * `registerDictionary`, `applyToDocument`, and `checkPluralCoverage`, which
245
+ * loads every language) or build new ones (`extend`,
246
+ * `extendPartial`). Instances of one contract over different language sets
247
+ * are different types, and every one of them is assignable to this, so code
248
+ * that only reads translations takes this rather than one instance's type:
249
+ * `LambderI18nReadonlyInstance<typeof en>` for the contract of an `en` block.
250
+ * Language codes read as plain strings here, the language set being what it
251
+ * leaves open.
252
+ */
253
+ export type LambderI18nReadonlyInstance<TContract extends Record<string, LambderI18nDictionaryEntry>> = Pick<LambderI18nInstance<Record<string, LambderLanguageMeta>, string, readonly string[], TContract>, "t" | "forLanguage" | "currentLanguage" | "currentLanguageMeta" | "currentDir" | "currentIntlLocale" | "onLanguageChange" | "isLanguageCode" | "languages" | "languageList" | "languageMetaList" | "defaultLanguage" | "enforced">;
164
254
  /** Language codes of an instance: `LambderI18nCodes<typeof i18n>`. */
165
255
  export type LambderI18nCodes<T extends {
166
256
  languageList: readonly string[];
@@ -173,5 +263,5 @@ export type LambderI18nKeys<T extends {
173
263
  export type LambderI18nTranslatorFor<T extends {
174
264
  t: unknown;
175
265
  }> = T["t"];
176
- export declare const createLambderI18n: <const TLanguages extends Record<string, LambderLanguageMeta>, const TDefault extends keyof TLanguages & string, const TEnforced extends readonly (keyof TLanguages & string)[], const TBase extends Record<TDefault, Record<string, string>>>(config: LambderI18nConfig<TLanguages, TDefault, TEnforced, TBase>) => LambderI18nInstance<TLanguages, TDefault, TEnforced, TBase[TDefault]>;
266
+ export declare const createLambderI18n: <const TLanguages extends Record<string, LambderLanguageMeta>, const TDefault extends keyof TLanguages & string, const TEnforced extends readonly (keyof TLanguages & string)[], const TBase extends Record<TDefault, Record<string, LambderI18nDictionaryEntry>>>(config: LambderI18nConfig<TLanguages, TDefault, TEnforced, TBase>) => LambderI18nInstance<TLanguages, TDefault, TEnforced, TBase[TDefault]>;
177
267
  export {};