mppx 0.5.5 → 0.5.7

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 (60) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/Html.d.ts +11 -0
  3. package/dist/Html.d.ts.map +1 -0
  4. package/dist/Html.js +41 -0
  5. package/dist/Html.js.map +1 -0
  6. package/dist/server/Mppx.d.ts +1 -28
  7. package/dist/server/Mppx.d.ts.map +1 -1
  8. package/dist/server/Mppx.js +101 -30
  9. package/dist/server/Mppx.js.map +1 -1
  10. package/dist/server/Transport.d.ts.map +1 -1
  11. package/dist/server/Transport.js +18 -46
  12. package/dist/server/Transport.js.map +1 -1
  13. package/dist/server/internal/html/compose.main.gen.d.ts +2 -0
  14. package/dist/server/internal/html/compose.main.gen.d.ts.map +1 -0
  15. package/dist/server/internal/html/compose.main.gen.js +3 -0
  16. package/dist/server/internal/html/compose.main.gen.js.map +1 -0
  17. package/dist/server/internal/html/config.d.ts +50 -49
  18. package/dist/server/internal/html/config.d.ts.map +1 -1
  19. package/dist/server/internal/html/config.js +323 -117
  20. package/dist/server/internal/html/config.js.map +1 -1
  21. package/dist/server/internal/html/constants.d.ts +26 -0
  22. package/dist/server/internal/html/constants.d.ts.map +1 -0
  23. package/dist/server/internal/html/constants.js +26 -0
  24. package/dist/server/internal/html/constants.js.map +1 -0
  25. package/dist/server/internal/html/serviceWorker.client.d.ts +2 -0
  26. package/dist/server/internal/html/serviceWorker.client.d.ts.map +1 -0
  27. package/dist/server/internal/html/serviceWorker.client.js +26 -0
  28. package/dist/server/internal/html/serviceWorker.client.js.map +1 -0
  29. package/dist/stripe/server/Charge.d.ts +2 -4
  30. package/dist/stripe/server/Charge.d.ts.map +1 -1
  31. package/dist/stripe/server/Charge.js.map +1 -1
  32. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  33. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  34. package/dist/stripe/server/internal/html.gen.js +1 -1
  35. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  36. package/dist/tempo/server/Charge.d.ts +1 -4
  37. package/dist/tempo/server/Charge.d.ts.map +1 -1
  38. package/dist/tempo/server/Charge.js.map +1 -1
  39. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  40. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  41. package/dist/tempo/server/internal/html.gen.js +1 -1
  42. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  43. package/package.json +6 -1
  44. package/src/Html.ts +67 -0
  45. package/src/server/Mppx.test.ts +203 -0
  46. package/src/server/Mppx.ts +118 -3
  47. package/src/server/Transport.test.ts +5 -2
  48. package/src/server/Transport.ts +21 -54
  49. package/src/server/internal/html/compose.main.gen.ts +2 -0
  50. package/src/server/internal/html/compose.main.ts +88 -0
  51. package/src/server/internal/html/config.ts +427 -177
  52. package/src/server/internal/html/constants.ts +28 -0
  53. package/src/server/internal/html/serviceWorker.client.ts +2 -2
  54. package/src/server/internal/html/tsconfig.compose.json +8 -0
  55. package/src/stripe/server/Charge.ts +2 -4
  56. package/src/stripe/server/internal/html/main.ts +44 -53
  57. package/src/stripe/server/internal/html.gen.ts +1 -1
  58. package/src/tempo/server/Charge.ts +1 -7
  59. package/src/tempo/server/internal/html/main.ts +26 -28
  60. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,ou9aAAou9a,CAAA"}
1
+ {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,q7+aAAq7+a,CAAA"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.5.5",
4
+ "version": "0.5.7",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "files": [
@@ -75,6 +75,11 @@
75
75
  "src": "./src/tempo/index.ts",
76
76
  "default": "./dist/tempo/index.js"
77
77
  },
78
+ "./html": {
79
+ "types": "./dist/Html.d.ts",
80
+ "src": "./src/Html.ts",
81
+ "default": "./dist/Html.js"
82
+ },
78
83
  "./hono": {
79
84
  "types": "./dist/middlewares/hono.d.ts",
80
85
  "src": "./src/middlewares/hono.ts",
package/src/Html.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { Json } from 'ox'
2
+
3
+ import type * as Method from './Method.js'
4
+ import {
5
+ attrs,
6
+ type Config,
7
+ type Data,
8
+ ids,
9
+ type Text,
10
+ type Theme,
11
+ vars,
12
+ } from './server/internal/html/config.js'
13
+ import { submitCredential } from './server/internal/html/serviceWorker.client.js'
14
+
15
+ export function init<
16
+ method extends Method.Method = Method.Method,
17
+ config extends Record<string, unknown> = {},
18
+ >(methodName: method['name']): Context<method, config> {
19
+ const element = document.getElementById(ids.data)!
20
+ const dataMap = Json.parse(element.textContent) as Record<string, Data<method, config>>
21
+
22
+ const remaining = element.getAttribute(attrs.remaining)
23
+ if (!remaining || Number(remaining) <= 1) element.remove()
24
+ else element.setAttribute(attrs.remaining, String(Number(remaining) - 1))
25
+
26
+ const script = document.currentScript
27
+ const challengeId = script?.getAttribute(attrs.challengeId)
28
+ const data = challengeId
29
+ ? (script!.removeAttribute(attrs.challengeId), dataMap[challengeId]!)
30
+ : Object.values(dataMap).find((d) => d.challenge.method === methodName)!
31
+
32
+ return {
33
+ ...data,
34
+ error(message?: string | null | undefined) {
35
+ if (!message) {
36
+ document.getElementById(ids.error)?.remove()
37
+ return
38
+ }
39
+ const existing = document.getElementById(ids.error)
40
+ if (existing) {
41
+ existing.textContent = message
42
+ return
43
+ }
44
+ const el = document.createElement('p')
45
+ el.id = ids.error
46
+ el.className = 'mppx-error'
47
+ el.role = 'alert'
48
+ el.textContent = message
49
+ document.getElementById(data.rootId)?.after(el)
50
+ },
51
+ root: document.getElementById(data.rootId)!,
52
+ submit: submitCredential,
53
+ vars,
54
+ }
55
+ }
56
+
57
+ export type Context<
58
+ method extends Method.Method = Method.Method,
59
+ config extends Record<string, unknown> = {},
60
+ > = Data<method, config> & {
61
+ error: (message?: string | null | undefined) => void
62
+ root: HTMLElement
63
+ submit: (credential: string) => Promise<void>
64
+ vars: typeof vars
65
+ }
66
+
67
+ export type { Config, Text, Theme }
@@ -1184,6 +1184,209 @@ describe('compose', () => {
1184
1184
 
1185
1185
  expect(result.status).toBe(200)
1186
1186
  })
1187
+
1188
+ describe('html', () => {
1189
+ const htmlOptionsA = {
1190
+ config: { providerA: true },
1191
+ content: '<script src="/alpha-bundle.js"></script>',
1192
+ formatAmount: (request: Record<string, unknown>) => `$${request.amount}`,
1193
+ text: undefined,
1194
+ theme: undefined,
1195
+ }
1196
+
1197
+ const htmlOptionsB = {
1198
+ config: { providerB: true },
1199
+ content: '<script src="/beta-bundle.js"></script>',
1200
+ formatAmount: (request: Record<string, unknown>) => `$${request.amount}`,
1201
+ text: undefined,
1202
+ theme: undefined,
1203
+ }
1204
+
1205
+ const alphaWithHtml = Method.toServer(mockChargeA, {
1206
+ html: htmlOptionsA,
1207
+ async verify() {
1208
+ return mockReceipt('alpha')
1209
+ },
1210
+ })
1211
+
1212
+ const betaWithHtml = Method.toServer(mockChargeB, {
1213
+ html: htmlOptionsB,
1214
+ async verify() {
1215
+ return mockReceipt('beta')
1216
+ },
1217
+ })
1218
+
1219
+ test('returns html with tabs when multiple methods have html', async () => {
1220
+ const mppx = Mppx.create({
1221
+ methods: [alphaWithHtml, betaWithHtml],
1222
+ realm,
1223
+ secretKey,
1224
+ })
1225
+
1226
+ const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts])
1227
+
1228
+ const result = await handle(
1229
+ new Request('https://example.com/resource', {
1230
+ headers: { Accept: 'text/html' },
1231
+ }),
1232
+ )
1233
+
1234
+ expect(result.status).toBe(402)
1235
+ if (result.status !== 402) throw new Error()
1236
+
1237
+ const body = await result.challenge.text()
1238
+ expect(result.challenge.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
1239
+
1240
+ // Tab a11y markup
1241
+ expect(body).toContain('role="tablist"')
1242
+ expect(body).toContain('role="tab"')
1243
+ expect(body).toContain('role="tabpanel"')
1244
+ expect(body).toContain('aria-selected="true"')
1245
+ expect(body).toContain('aria-controls="mppx-panel-0"')
1246
+ expect(body).toContain('aria-controls="mppx-panel-1"')
1247
+
1248
+ // Tab labels from method names (capitalized via CSS)
1249
+ expect(body).toContain('alpha')
1250
+ expect(body).toContain('beta')
1251
+
1252
+ // Both method bundles included
1253
+ expect(body).toContain('/alpha-bundle.js')
1254
+ expect(body).toContain('/beta-bundle.js')
1255
+
1256
+ // Data map with both entries
1257
+ const dataMatch = body.match(
1258
+ /<script[^>]*id="__MPPX_DATA__"[^>]*type="application\/json"[^>]*>\s*([\s\S]*?)\s*<\/script>/s,
1259
+ )
1260
+ expect(dataMatch).not.toBeNull()
1261
+ const dataMap = JSON.parse(dataMatch![1]!.replace(/\\u003c/g, '<'))
1262
+ const dataValues = Object.values(dataMap) as { label: string; config: unknown }[]
1263
+ expect(dataValues).toHaveLength(2)
1264
+ expect(dataValues[0]!.label).toBe('alpha')
1265
+ expect(dataValues[0]!.config).toEqual({ providerA: true })
1266
+ expect(dataValues[1]!.label).toBe('beta')
1267
+ expect(dataValues[1]!.config).toEqual({ providerB: true })
1268
+ })
1269
+
1270
+ test('returns html without tabs when single method has html', async () => {
1271
+ const mppx = Mppx.create({
1272
+ methods: [alphaWithHtml, betaMethod],
1273
+ realm,
1274
+ secretKey,
1275
+ })
1276
+
1277
+ const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaMethod, challengeOpts])
1278
+
1279
+ const result = await handle(
1280
+ new Request('https://example.com/resource', {
1281
+ headers: { Accept: 'text/html' },
1282
+ }),
1283
+ )
1284
+
1285
+ expect(result.status).toBe(402)
1286
+ if (result.status !== 402) throw new Error()
1287
+
1288
+ const body = await result.challenge.text()
1289
+ expect(result.challenge.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
1290
+
1291
+ // No tabs when only one method has html
1292
+ expect(body).not.toContain('role="tablist"')
1293
+ expect(body).not.toContain('role="tab"')
1294
+
1295
+ // Single panel present
1296
+ expect(body).toContain('mppx-panel-0')
1297
+ expect(body).toContain('/alpha-bundle.js')
1298
+
1299
+ // Data map with single entry
1300
+ const dataMatch = body.match(
1301
+ /<script[^>]*id="__MPPX_DATA__"[^>]*type="application\/json"[^>]*>\s*([\s\S]*?)\s*<\/script>/s,
1302
+ )
1303
+ const dataMap = JSON.parse(dataMatch![1]!.replace(/\\u003c/g, '<'))
1304
+ const dataValues = Object.values(dataMap) as { label: string }[]
1305
+ expect(dataValues).toHaveLength(1)
1306
+ expect(dataValues[0]!.label).toBe('alpha')
1307
+ })
1308
+
1309
+ test('falls back to json when Accept does not include text/html', async () => {
1310
+ const mppx = Mppx.create({
1311
+ methods: [alphaWithHtml, betaWithHtml],
1312
+ realm,
1313
+ secretKey,
1314
+ })
1315
+
1316
+ const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts])
1317
+
1318
+ const result = await handle(new Request('https://example.com/resource'))
1319
+
1320
+ expect(result.status).toBe(402)
1321
+ if (result.status !== 402) throw new Error()
1322
+
1323
+ const contentType = result.challenge.headers.get('Content-Type')
1324
+ expect(contentType).not.toContain('text/html')
1325
+ })
1326
+
1327
+ test('serves service worker when __mppx_worker param is set', async () => {
1328
+ const mppx = Mppx.create({
1329
+ methods: [alphaWithHtml, betaWithHtml],
1330
+ realm,
1331
+ secretKey,
1332
+ })
1333
+
1334
+ const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts])
1335
+
1336
+ const result = await handle(new Request('https://example.com/resource?__mppx_worker'))
1337
+
1338
+ expect(result.status).toBe(402)
1339
+ if (result.status !== 402) throw new Error()
1340
+
1341
+ expect(result.challenge.status).toBe(200)
1342
+ expect(result.challenge.headers.get('Content-Type')).toBe('application/javascript')
1343
+ })
1344
+
1345
+ test('returns json when no methods have html configured', async () => {
1346
+ const mppx = Mppx.create({
1347
+ methods: [alphaMethod, betaMethod],
1348
+ realm,
1349
+ secretKey,
1350
+ })
1351
+
1352
+ const handle = mppx.compose([alphaMethod, challengeOpts], [betaMethod, challengeOpts])
1353
+
1354
+ const result = await handle(
1355
+ new Request('https://example.com/resource', {
1356
+ headers: { Accept: 'text/html' },
1357
+ }),
1358
+ )
1359
+
1360
+ expect(result.status).toBe(402)
1361
+ if (result.status !== 402) throw new Error()
1362
+
1363
+ const contentType = result.challenge.headers.get('Content-Type')
1364
+ expect(contentType).not.toContain('text/html')
1365
+ })
1366
+
1367
+ test('both WWW-Authenticate headers present even with html', async () => {
1368
+ const mppx = Mppx.create({
1369
+ methods: [alphaWithHtml, betaWithHtml],
1370
+ realm,
1371
+ secretKey,
1372
+ })
1373
+
1374
+ const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts])
1375
+
1376
+ const result = await handle(
1377
+ new Request('https://example.com/resource', {
1378
+ headers: { Accept: 'text/html' },
1379
+ }),
1380
+ )
1381
+
1382
+ expect(result.status).toBe(402)
1383
+ if (result.status !== 402) throw new Error()
1384
+
1385
+ const wwwAuth = result.challenge.headers.get('WWW-Authenticate')!
1386
+ expect(wwwAuth).toContain('method="alpha"')
1387
+ expect(wwwAuth).toContain('method="beta"')
1388
+ })
1389
+ })
1187
1390
  })
1188
1391
 
1189
1392
  describe('compose: pre-dispatch narrowing edge cases', () => {
@@ -10,6 +10,8 @@ import type * as Method from '../Method.js'
10
10
  import * as PaymentRequest from '../PaymentRequest.js'
11
11
  import type * as Receipt from '../Receipt.js'
12
12
  import type * as z from '../zod.js'
13
+ import * as Html from './internal/html/config.js'
14
+ import { serviceWorker } from './internal/html/serviceWorker.gen.js'
13
15
  import * as NodeListener from './NodeListener.js'
14
16
  import * as Request from './Request.js'
15
17
  import * as Transport from './Transport.js'
@@ -645,6 +647,7 @@ type ConfiguredHandler = ((input: Request) => Promise<MethodFn.Response<Transpor
645
647
  _internal: {
646
648
  name: string
647
649
  intent: string
650
+ html: Html.Options | undefined
648
651
  _canonicalRequest: Record<string, unknown>
649
652
  }
650
653
  }
@@ -697,12 +700,52 @@ type ComposeEntry<methods extends readonly Method.AnyServer[]> =
697
700
  * })
698
701
  * ```
699
702
  */
703
+ type ComposeHtmlOptions = Html.Config
704
+
700
705
  export function compose(
701
- ...handlers: readonly ((input: Request) => Promise<MethodFn.Response<Transport.Http>>)[]
706
+ ...args: readonly unknown[]
702
707
  ): (input: Request) => Promise<MethodFn.Response<Transport.Http>> {
708
+ // Extract optional html options from last argument
709
+ const last = args[args.length - 1]
710
+ const composeOptions: Html.Options | undefined =
711
+ typeof last === 'object' &&
712
+ last !== null &&
713
+ typeof last !== 'function' &&
714
+ !('_internal' in last)
715
+ ? (() => {
716
+ const opts = last as ComposeHtmlOptions
717
+ return {
718
+ config: {},
719
+ content: '',
720
+ formatAmount: () => '',
721
+ text: opts.text,
722
+ theme: opts.theme,
723
+ }
724
+ })()
725
+ : undefined
726
+ const handlers = (composeOptions ? args.slice(0, -1) : args) as readonly ((
727
+ input: Request,
728
+ ) => Promise<MethodFn.Response<Transport.Http>>)[]
729
+
703
730
  if (handlers.length === 0) throw new Error('compose() requires at least one handler')
704
731
 
705
732
  return async (input: Request) => {
733
+ // Serve service worker for html-enabled compose
734
+ if (new URL(input.url).searchParams.has(Html.params.serviceWorker)) {
735
+ const hasHtml = handlers.some((h) => (h as ConfiguredHandler)._internal?.html)
736
+ if (hasHtml)
737
+ return {
738
+ status: 402,
739
+ challenge: new Response(serviceWorker, {
740
+ status: 200,
741
+ headers: {
742
+ 'Content-Type': 'application/javascript',
743
+ 'Cache-Control': 'no-store',
744
+ },
745
+ }),
746
+ } as MethodFn.Response<Transport.Http>
747
+ }
748
+
706
749
  // Try to extract a Payment credential to decide whether to dispatch or challenge.
707
750
  // Only gate on the Payment scheme — other auth schemes (Bearer, Basic, etc.)
708
751
  // should fall through to the merged-402 path so all offers are presented.
@@ -754,17 +797,89 @@ export function compose(
754
797
  const mergedHeaders = new Headers()
755
798
  mergedHeaders.set('Cache-Control', 'no-store')
756
799
 
757
- let body: string | null = null
758
800
  for (const result of results) {
759
801
  if (result.status !== 402) continue
760
802
  const response = result.challenge as Response
761
803
  const wwwAuth = response.headers.get('WWW-Authenticate')
762
804
  if (wwwAuth) mergedHeaders.append('WWW-Authenticate', wwwAuth)
763
- // Use the first handler's body for the problem details response.
805
+ }
806
+
807
+ // Collect html-enabled handlers and their challenges
808
+ const htmlEntries = (() => {
809
+ const entries: {
810
+ handler: ConfiguredHandler
811
+ challenge: Challenge.Challenge
812
+ }[] = []
813
+ for (let i = 0; i < handlers.length; i++) {
814
+ const meta = (handlers[i] as ConfiguredHandler)._internal
815
+ if (!meta?.html) continue
816
+ const result = results[i]
817
+ if (result?.status !== 402) continue
818
+ const wwwAuth = result.challenge.headers.get('WWW-Authenticate')
819
+ if (!wwwAuth) continue
820
+ entries.push({
821
+ handler: handlers[i] as ConfiguredHandler,
822
+ challenge: Challenge.deserialize(wwwAuth),
823
+ })
824
+ }
825
+ return entries
826
+ })()
827
+
828
+ const wantsHtml = input.headers.get('Accept')?.includes('text/html')
829
+ if (wantsHtml && htmlEntries.length > 0) {
830
+ const { theme, text } = Html.resolveOptions(
831
+ // Use compose-level options or first html-enabled method's config for the page shell
832
+ composeOptions ?? htmlEntries[0]?.handler._internal.html ?? ({} as Html.Options),
833
+ )
834
+
835
+ // Build data map keyed by challenge.id
836
+ const dataMap: Record<string, Html.Data> = {}
837
+ for (let i = 0; i < htmlEntries.length; i++) {
838
+ const entry = htmlEntries[i]!
839
+ dataMap[entry.challenge.id] = {
840
+ label: entry.handler._internal.name,
841
+ rootId: `${Html.ids.root}-${i}`,
842
+ formattedAmount: await entry.handler._internal.html!.formatAmount(
843
+ entry.challenge.request,
844
+ ),
845
+ config: entry.handler._internal.html!.config,
846
+ challenge: entry.challenge as never,
847
+ text,
848
+ theme,
849
+ }
850
+ }
851
+
852
+ mergedHeaders.set('Content-Type', 'text/html; charset=utf-8')
853
+
854
+ const firstData = Object.values(dataMap)[0]!
855
+ const body = Html.render({
856
+ entries: htmlEntries.map((entry) => ({
857
+ challenge: entry.challenge,
858
+ content: entry.handler._internal.html!.content,
859
+ })),
860
+ dataMap,
861
+ formattedAmount: firstData.formattedAmount,
862
+ panels: true,
863
+ text,
864
+ theme,
865
+ })
866
+
867
+ return {
868
+ status: 402,
869
+ challenge: new Response(body, { status: 402, headers: mergedHeaders }),
870
+ }
871
+ }
872
+
873
+ // Non-HTML fallback: use first handler's body
874
+ let body: string | null = null
875
+ for (const result of results) {
876
+ if (result.status !== 402) continue
764
877
  if (!body) {
878
+ const response = result.challenge as Response
765
879
  const contentType = response.headers.get('Content-Type')
766
880
  if (contentType) mergedHeaders.set('Content-Type', contentType)
767
881
  body = await response.text()
882
+ break
768
883
  }
769
884
  }
770
885
 
@@ -285,11 +285,14 @@ describe('http', () => {
285
285
  const body = await response.text()
286
286
  // Extract the JSON data from the script tag
287
287
  const dataMatch = body.match(
288
- /<script id="__MPPX_DATA__" type="application\/json">\s*([\s\S]*?)\s*<\/script>/,
288
+ /<script[^>]*id="__MPPX_DATA__"[^>]*type="application\/json"[^>]*>\s*([\s\S]*?)\s*<\/script>/s,
289
289
  )
290
290
  expect(dataMatch).not.toBeNull()
291
291
 
292
- const data = JSON.parse(dataMatch?.[1]?.replace(/\\u003c/g, '<') ?? '')
292
+ const dataMap = JSON.parse(dataMatch?.[1]?.replace(/\\u003c/g, '<') ?? '')
293
+ expect(typeof dataMap).toBe('object')
294
+ expect(Object.keys(dataMap)).toHaveLength(1)
295
+ const data = dataMap[challenge.id]
293
296
  expect(data.config).toEqual({ foo: 'bar' })
294
297
  expect(data.challenge.id).toBe(challenge.id)
295
298
  expect(data.challenge.method).toBe('tempo')
@@ -1,5 +1,3 @@
1
- import { Json } from 'ox'
2
-
3
1
  import * as Challenge from '../Challenge.js'
4
2
  import * as Credential from '../Credential.js'
5
3
  import * as Errors from '../Errors.js'
@@ -7,7 +5,6 @@ import type { Distribute, UnionToIntersection } from '../internal/types.js'
7
5
  import * as core_Mcp from '../Mcp.js'
8
6
  import * as Receipt from '../Receipt.js'
9
7
  import * as Html from './internal/html/config.js'
10
- import { html } from './internal/html/config.js'
11
8
  import { serviceWorker } from './internal/html/serviceWorker.gen.js'
12
9
 
13
10
  export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js'
@@ -132,7 +129,7 @@ export function http(): Http {
132
129
  async respondChallenge(options) {
133
130
  const { challenge, error, input } = options
134
131
 
135
- if (options.html && new URL(input.url).searchParams.has(Html.serviceWorkerParam))
132
+ if (options.html && new URL(input.url).searchParams.has(Html.params.serviceWorker))
136
133
  return new Response(serviceWorker, {
137
134
  status: 200,
138
135
  headers: {
@@ -150,58 +147,28 @@ export function http(): Http {
150
147
  if (options.html && input.headers.get('Accept')?.includes('text/html')) {
151
148
  headers['Content-Type'] = 'text/html; charset=utf-8'
152
149
 
153
- const theme = Html.mergeDefined(
154
- {
155
- favicon: undefined as Html.Theme['favicon'],
156
- fontUrl: undefined as Html.Theme['fontUrl'],
157
- logo: undefined as Html.Theme['logo'],
158
- ...Html.defaultTheme,
159
- },
160
- (options.html.theme as never) ?? {},
161
- )
162
- const text = Html.sanitizeRecord(
163
- Html.mergeDefined(Html.defaultText, (options.html.text as never) ?? {}),
164
- )
150
+ const { theme, text } = Html.resolveOptions(options.html)
165
151
  const amount = await options.html.formatAmount(challenge.request)
166
152
 
167
- return html`<!doctype html>
168
- <html lang="en">
169
- <head>
170
- <meta charset="UTF-8" />
171
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
172
- <meta name="robots" content="noindex" />
173
- <meta name="color-scheme" content="${theme.colorScheme}" />
174
- <title>${text.title}</title>
175
- ${Html.favicon(theme, challenge.realm)} ${Html.font(theme)} ${Html.style(theme)}
176
- </head>
177
- <body>
178
- <main>
179
- <header class="${Html.classNames.header}">
180
- ${Html.logo(theme)}
181
- <span>${text.paymentRequired}</span>
182
- </header>
183
- <section class="${Html.classNames.summary}" aria-label="Payment summary">
184
- <h1 class="${Html.classNames.summaryAmount}">${Html.sanitize(amount)}</h1>
185
- ${challenge.description
186
- ? `<p class="${Html.classNames.summaryDescription}">${Html.sanitize(challenge.description)}</p>`
187
- : ''}
188
- ${challenge.expires
189
- ? `<p class="${Html.classNames.summaryExpires}">${text.expires} <time datetime="${new Date(challenge.expires).toISOString()}">${new Date(challenge.expires).toLocaleString()}</time></p>`
190
- : ''}
191
- </section>
192
- <div id="${Html.rootId}" aria-label="Payment form"></div>
193
- <script id="${Html.dataId}" type="application/json">
194
- ${Json.stringify({
195
- config: options.html.config,
196
- challenge,
197
- text,
198
- theme,
199
- } satisfies Html.Data).replace(/</g, '\\u003c')}
200
- </script>
201
- ${options.html.content}
202
- </main>
203
- </body>
204
- </html> `
153
+ const dataMap = {
154
+ [challenge.id]: {
155
+ label: challenge.method,
156
+ rootId: Html.ids.root,
157
+ formattedAmount: amount,
158
+ config: options.html.config,
159
+ challenge,
160
+ text,
161
+ theme,
162
+ },
163
+ } satisfies Record<string, Html.Data>
164
+
165
+ return Html.render({
166
+ entries: [{ challenge, content: options.html.content }],
167
+ dataMap,
168
+ formattedAmount: amount,
169
+ text,
170
+ theme,
171
+ })
205
172
  }
206
173
  if (error) {
207
174
  headers['Content-Type'] = 'application/problem+json'
@@ -0,0 +1,2 @@
1
+ // Generated — do not edit.
2
+ export const tabScript = "<script>(function(){let e={serviceWorker:`__mppx_worker`,tab:`__mppx_tab`},t={error:`mppx-error`,header:`mppx-header`,logo:`mppx-logo`,summary:`mppx-summary`,summaryAmount:`mppx-summary-amount`,summaryDescription:`mppx-summary-description`,summaryExpires:`mppx-summary-expires`,tab:`mppx-tab`,tabList:`mppx-tablist`,tabPanel:`mppx-tabpanel`},n=document.querySelector(`.${t.tabList}`),r=document.querySelector(`.${t.summary}`),i=r.querySelector(`.${t.summaryAmount}`),a=Array.from(n.querySelectorAll(`[role=\"tab\"]`)),o=[],s={};for(let e of a){let t=e.textContent.trim().toLowerCase();s[t]=(s[t]||0)+1,o.push(s[t]===1?t:`${t}-${s[t]}`)}function c(e){if(i.textContent=e.dataset.amount,r.querySelector(`.${t.summaryDescription}`)?.remove(),e.dataset.description){let n=document.createElement(`p`);n.className=t.summaryDescription,n.textContent=e.dataset.description,i.after(n)}if(r.querySelector(`.${t.summaryExpires}`)?.remove(),e.dataset.expires){let n=document.createElement(`p`);n.className=t.summaryExpires;let i=new Date(e.dataset.expires),a=document.createElement(`time`);a.dateTime=i.toISOString(),a.textContent=i.toLocaleString(),n.textContent=`${e.dataset.expiresLabel} `,n.appendChild(a),r.appendChild(n)}}function l(t,n=!0){if(a.forEach(e=>{e.setAttribute(`aria-selected`,`false`),e.setAttribute(`tabindex`,`-1`)}),t.setAttribute(`aria-selected`,`true`),t.removeAttribute(`tabindex`),t.focus(),document.querySelectorAll(`[role=\"tabpanel\"]`).forEach(e=>{e.hidden=!0}),document.getElementById(t.getAttribute(`aria-controls`)).hidden=!1,c(t),n){let n=new URL(location.href);n.searchParams.set(e.tab,o[a.indexOf(t)]),history.replaceState(null,``,n)}}let u=new URL(location.href).searchParams.get(e.tab);if(u!==null){let e=o.indexOf(u);e>=0&&l(a[e],!1)}n.addEventListener(`click`,e=>{let t=e.target.closest(`[role=\"tab\"]`);t&&l(t)}),n.addEventListener(`keydown`,e=>{let t=a.indexOf(e.target);if(t<0)return;let n;e.key===`ArrowRight`?n=a[(t+1)%a.length]:e.key===`ArrowLeft`?n=a[(t-1+a.length)%a.length]:e.key===`Home`?n=a[0]:e.key===`End`&&(n=a[a.length-1]),n&&(e.preventDefault(),l(n))})})();</script>"
@@ -0,0 +1,88 @@
1
+ import { classNames, params } from './constants.js'
2
+
3
+ const tablist = document.querySelector<HTMLElement>(`.${classNames.tabList}`)!
4
+ const summary = document.querySelector<HTMLElement>(`.${classNames.summary}`)!
5
+ const amount = summary.querySelector<HTMLElement>(`.${classNames.summaryAmount}`)!
6
+ const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'))
7
+
8
+ // Generate unique slugs: tempo, stripe, stripe-2
9
+ const slugs: string[] = []
10
+ const counts: Record<string, number> = {}
11
+ for (const tab of tabs) {
12
+ const name = tab.textContent!.trim().toLowerCase()
13
+ counts[name] = (counts[name] || 0) + 1
14
+ slugs.push(counts[name] === 1 ? name : `${name}-${counts[name]}`)
15
+ }
16
+
17
+ function updateSummary(tab: HTMLElement) {
18
+ amount.textContent = tab.dataset.amount!
19
+
20
+ summary.querySelector(`.${classNames.summaryDescription}`)?.remove()
21
+ if (tab.dataset.description) {
22
+ const p = document.createElement('p')
23
+ p.className = classNames.summaryDescription
24
+ p.textContent = tab.dataset.description
25
+ amount.after(p)
26
+ }
27
+
28
+ summary.querySelector(`.${classNames.summaryExpires}`)?.remove()
29
+ if (tab.dataset.expires) {
30
+ const p = document.createElement('p')
31
+ p.className = classNames.summaryExpires
32
+ const date = new Date(tab.dataset.expires)
33
+ const time = document.createElement('time')
34
+ time.dateTime = date.toISOString()
35
+ time.textContent = date.toLocaleString()
36
+ p.textContent = `${tab.dataset.expiresLabel} `
37
+ p.appendChild(time)
38
+ summary.appendChild(p)
39
+ }
40
+ }
41
+
42
+ function activate(tab: HTMLElement, updateUrl = true) {
43
+ tabs.forEach((t) => {
44
+ t.setAttribute('aria-selected', 'false')
45
+ t.setAttribute('tabindex', '-1')
46
+ })
47
+ tab.setAttribute('aria-selected', 'true')
48
+ tab.removeAttribute('tabindex')
49
+ tab.focus()
50
+ document.querySelectorAll<HTMLElement>('[role="tabpanel"]').forEach((p) => {
51
+ p.hidden = true
52
+ })
53
+ document.getElementById(tab.getAttribute('aria-controls')!)!.hidden = false
54
+
55
+ updateSummary(tab)
56
+
57
+ if (updateUrl) {
58
+ const url = new URL(location.href)
59
+ url.searchParams.set(params.tab, slugs[tabs.indexOf(tab)]!)
60
+ history.replaceState(null, '', url)
61
+ }
62
+ }
63
+
64
+ // Restore tab from URL on load
65
+ const initial = new URL(location.href).searchParams.get(params.tab)
66
+ if (initial !== null) {
67
+ const index = slugs.indexOf(initial)
68
+ if (index >= 0) activate(tabs[index]!, false)
69
+ }
70
+
71
+ tablist.addEventListener('click', (event) => {
72
+ const tab = (event.target as HTMLElement).closest<HTMLElement>('[role="tab"]')
73
+ if (tab) activate(tab)
74
+ })
75
+
76
+ tablist.addEventListener('keydown', (event) => {
77
+ const index = tabs.indexOf(event.target as HTMLElement)
78
+ if (index < 0) return
79
+ let next: HTMLElement | undefined
80
+ if (event.key === 'ArrowRight') next = tabs[(index + 1) % tabs.length]
81
+ else if (event.key === 'ArrowLeft') next = tabs[(index - 1 + tabs.length) % tabs.length]
82
+ else if (event.key === 'Home') next = tabs[0]
83
+ else if (event.key === 'End') next = tabs[tabs.length - 1]
84
+ if (next) {
85
+ event.preventDefault()
86
+ activate(next)
87
+ }
88
+ })