Skip to content

Commit 1a6dc2b

Browse files
authored
feat(html): public api (#294)
* feat(html): public api * chore: tweaks * chore: up
1 parent 0c4ce6f commit 1a6dc2b

File tree

11 files changed

+203
-163
lines changed

11 files changed

+203
-163
lines changed

AGENTS.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,13 @@ The changelog is auto-generated from changesets during `changeset version`.
161161
## Commands
162162

163163
```bash
164-
pnpm build # Build with zile
165-
pnpm check # Lint with oxlint + format with oxfmt
166-
pnpm check:types # TypeScript type checking
167-
pnpm test # Run tests with vitest
164+
pnpm build # Build HTML with rolldown and library with zile
165+
pnpm check # Lint with oxlint + format with oxfmt
166+
pnpm check:types # TypeScript type checking
167+
pnpm check:types:examples # Examples type checking
168+
pnpm check:types:html # HTML type checking
169+
pnpm test # Run tests with vitest
170+
pnpm test:html # Run HTML tests with playwright
168171
```
169172

170173
## Skills Reference

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@
131131
"src": "./src/tempo/index.ts",
132132
"default": "./dist/tempo/index.js"
133133
},
134+
"./html": {
135+
"types": "./dist/Html.d.ts",
136+
"src": "./src/Html.ts",
137+
"default": "./dist/Html.js"
138+
},
134139
"./hono": {
135140
"types": "./dist/middlewares/hono.d.ts",
136141
"src": "./src/middlewares/hono.ts",

src/Html.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Json } from 'ox'
2+
3+
import type * as Method from './Method.js'
4+
import { attrs, type Data, ids, vars } from './server/internal/html/config.js'
5+
import { submitCredential } from './server/internal/html/serviceWorker.client.js'
6+
7+
export function init<
8+
method extends Method.Method = Method.Method,
9+
config extends Record<string, unknown> = {},
10+
>(methodName: method['name']): Context<method, config> {
11+
const element = document.getElementById(ids.data)!
12+
const dataMap = Json.parse(element.textContent) as Record<string, Data<method, config>>
13+
14+
const remaining = element.getAttribute(attrs.remaining)
15+
if (!remaining || Number(remaining) <= 1) element.remove()
16+
else element.setAttribute(attrs.remaining, String(Number(remaining) - 1))
17+
18+
const script = document.currentScript
19+
const challengeId = script?.getAttribute(attrs.challengeId)
20+
const data = challengeId
21+
? (script!.removeAttribute(attrs.challengeId), dataMap[challengeId]!)
22+
: Object.values(dataMap).find((d) => d.challenge.method === methodName)!
23+
24+
return {
25+
...data,
26+
error(message?: string | null | undefined) {
27+
if (!message) {
28+
document.getElementById(ids.error)?.remove()
29+
return
30+
}
31+
const existing = document.getElementById(ids.error)
32+
if (existing) {
33+
existing.textContent = message
34+
return
35+
}
36+
const el = document.createElement('p')
37+
el.id = ids.error
38+
el.className = 'mppx-error'
39+
el.role = 'alert'
40+
el.textContent = message
41+
document.getElementById(data.rootId)?.after(el)
42+
},
43+
root: document.getElementById(data.rootId)!,
44+
submit: submitCredential,
45+
vars,
46+
}
47+
}
48+
49+
export type Context<
50+
method extends Method.Method = Method.Method,
51+
config extends Record<string, unknown> = {},
52+
> = Data<method, config> & {
53+
error: (message?: string | null | undefined) => void
54+
root: HTMLElement
55+
submit: (credential: string) => Promise<void>
56+
vars: typeof vars
57+
}

src/server/Mppx.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,7 @@ export function compose(
731731

732732
return async (input: Request) => {
733733
// Serve service worker for html-enabled compose
734-
if (new URL(input.url).searchParams.has(Html.serviceWorkerParam)) {
734+
if (new URL(input.url).searchParams.has(Html.params.serviceWorker)) {
735735
const hasHtml = handlers.some((h) => (h as ConfiguredHandler)._internal?.html)
736736
if (hasHtml)
737737
return {
@@ -838,7 +838,7 @@ export function compose(
838838
const entry = htmlEntries[i]!
839839
dataMap[entry.challenge.id] = {
840840
label: entry.handler._internal.name,
841-
rootId: `${Html.rootId}-${i}`,
841+
rootId: `${Html.ids.root}-${i}`,
842842
formattedAmount: await entry.handler._internal.html!.formatAmount(
843843
entry.challenge.request,
844844
),

src/server/Transport.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export function http(): Http {
129129
async respondChallenge(options) {
130130
const { challenge, error, input } = options
131131

132-
if (options.html && new URL(input.url).searchParams.has(Html.serviceWorkerParam))
132+
if (options.html && new URL(input.url).searchParams.has(Html.params.serviceWorker))
133133
return new Response(serviceWorker, {
134134
status: 200,
135135
headers: {
@@ -153,7 +153,7 @@ export function http(): Http {
153153
const dataMap = {
154154
[challenge.id]: {
155155
label: challenge.method,
156-
rootId: Html.rootId,
156+
rootId: Html.ids.root,
157157
formattedAmount: amount,
158158
config: options.html.config,
159159
challenge,

src/server/internal/html/compose.main.ts

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
const tablist = document.querySelector<HTMLElement>('.mppx-tablist')!
2-
const summary = document.querySelector<HTMLElement>('.mppx-summary')!
3-
const amountEl = summary.querySelector<HTMLElement>('.mppx-summary-amount')!
4-
const param = '__mppx_tab'
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}`)!
56
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'))
67

78
// Generate unique slugs: tempo, stripe, stripe-2
@@ -14,20 +15,20 @@ for (const tab of tabs) {
1415
}
1516

1617
function updateSummary(tab: HTMLElement) {
17-
amountEl.textContent = tab.dataset.amount!
18+
amount.textContent = tab.dataset.amount!
1819

19-
summary.querySelector('.mppx-summary-description')?.remove()
20+
summary.querySelector(`.${classNames.summaryDescription}`)?.remove()
2021
if (tab.dataset.description) {
2122
const p = document.createElement('p')
22-
p.className = 'mppx-summary-description'
23+
p.className = classNames.summaryDescription
2324
p.textContent = tab.dataset.description
24-
amountEl.after(p)
25+
amount.after(p)
2526
}
2627

27-
summary.querySelector('.mppx-summary-expires')?.remove()
28+
summary.querySelector(`.${classNames.summaryExpires}`)?.remove()
2829
if (tab.dataset.expires) {
2930
const p = document.createElement('p')
30-
p.className = 'mppx-summary-expires'
31+
p.className = classNames.summaryExpires
3132
const date = new Date(tab.dataset.expires)
3233
const time = document.createElement('time')
3334
time.dateTime = date.toISOString()
@@ -55,33 +56,33 @@ function activate(tab: HTMLElement, updateUrl = true) {
5556

5657
if (updateUrl) {
5758
const url = new URL(location.href)
58-
url.searchParams.set(param, slugs[tabs.indexOf(tab)]!)
59+
url.searchParams.set(params.tab, slugs[tabs.indexOf(tab)]!)
5960
history.replaceState(null, '', url)
6061
}
6162
}
6263

6364
// Restore tab from URL on load
64-
const initial = new URL(location.href).searchParams.get(param)
65+
const initial = new URL(location.href).searchParams.get(params.tab)
6566
if (initial !== null) {
66-
const idx = slugs.indexOf(initial)
67-
if (idx >= 0) activate(tabs[idx]!, false)
67+
const index = slugs.indexOf(initial)
68+
if (index >= 0) activate(tabs[index]!, false)
6869
}
6970

70-
tablist.addEventListener('click', (e) => {
71-
const tab = (e.target as HTMLElement).closest<HTMLElement>('[role="tab"]')
71+
tablist.addEventListener('click', (event) => {
72+
const tab = (event.target as HTMLElement).closest<HTMLElement>('[role="tab"]')
7273
if (tab) activate(tab)
7374
})
7475

75-
tablist.addEventListener('keydown', (e) => {
76-
const idx = tabs.indexOf(e.target as HTMLElement)
77-
if (idx < 0) return
76+
tablist.addEventListener('keydown', (event) => {
77+
const index = tabs.indexOf(event.target as HTMLElement)
78+
if (index < 0) return
7879
let next: HTMLElement | undefined
79-
if (e.key === 'ArrowRight') next = tabs[(idx + 1) % tabs.length]
80-
else if (e.key === 'ArrowLeft') next = tabs[(idx - 1 + tabs.length) % tabs.length]
81-
else if (e.key === 'Home') next = tabs[0]
82-
else if (e.key === 'End') next = tabs[tabs.length - 1]
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]
8384
if (next) {
84-
e.preventDefault()
85+
event.preventDefault()
8586
activate(next)
8687
}
8788
})

src/server/internal/html/config.ts

Lines changed: 14 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -27,62 +27,13 @@ export type Data<
2727
}
2828
}
2929

30-
export const errorId = 'root_error'
31-
export const rootId = 'root'
32-
const dataId = '__MPPX_DATA__'
30+
export { attrs, classNames, ids, params } from './constants.js'
31+
import { attrs, classNames, ids } from './constants.js'
3332

34-
export const serviceWorkerParam = '__mppx_worker'
35-
36-
const challengeIdAttr = 'data-mppx-challenge-id'
37-
const remainingAttr = 'data-remaining'
38-
39-
export function getData<
40-
method extends Method.Method = Method.Method,
41-
config extends Record<string, unknown> = {},
42-
>(methodName: method['name']): Data<method, config> {
43-
const el = document.getElementById(dataId)!
44-
const map = Json.parse(el.textContent) as Record<string, Data<method, config>>
45-
const remaining = el.getAttribute(remainingAttr)
46-
if (!remaining || Number(remaining) <= 1) el.remove()
47-
else el.setAttribute(remainingAttr, String(Number(remaining) - 1))
48-
const script = document.currentScript
49-
const challengeId = script?.getAttribute(challengeIdAttr)
50-
if (challengeId) {
51-
script!.removeAttribute(challengeIdAttr)
52-
return map[challengeId]!
53-
}
54-
return Object.values(map).find((d) => d.challenge.method === methodName)!
55-
}
56-
57-
export function showError(message: string) {
58-
const existing = document.getElementById(errorId)
59-
if (existing) {
60-
existing.textContent = message
61-
return
62-
}
63-
const el = document.createElement('p')
64-
el.id = errorId
65-
el.className = classNames.error
66-
el.role = 'alert'
67-
el.textContent = message
68-
document.getElementById(rootId)?.after(el)
69-
}
70-
71-
const classNames = {
72-
error: 'mppx-error',
73-
header: 'mppx-header',
74-
logo: 'mppx-logo',
75-
logoColorScheme: (colorScheme: string) =>
76-
colorScheme === 'dark' || colorScheme === 'light'
77-
? `${classNames.logo}--${colorScheme}`
78-
: undefined,
79-
summary: 'mppx-summary',
80-
summaryAmount: 'mppx-summary-amount',
81-
summaryDescription: 'mppx-summary-description',
82-
summaryExpires: 'mppx-summary-expires',
83-
tab: 'mppx-tab',
84-
tabList: 'mppx-tablist',
85-
tabPanel: 'mppx-tabpanel',
33+
function logoColorScheme(colorScheme: string) {
34+
return colorScheme === 'dark' || colorScheme === 'light'
35+
? `${classNames.logo}--${colorScheme}`
36+
: undefined
8637
}
8738

8839
class CssVar {
@@ -282,18 +233,18 @@ export function render(options: {
282233
id="mppx-panel-${i}"
283234
${i !== 0 ? 'hidden' : ''}
284235
>
285-
<div id="${rootId}-${i}" aria-label="Payment form"></div>
236+
<div id="${ids.root}-${i}" aria-label="Payment form"></div>
286237
</div>`,
287238
)
288239
.join('')
289-
: html`<div id="${rootId}" aria-label="Payment form"></div>`
240+
: html`<div id="${ids.root}" aria-label="Payment form"></div>`
290241

291242
const contentScripts = hasTabs
292243
? entries
293244
.map((entry) =>
294245
entry.content.replace(
295246
'<script>',
296-
`<script ${challengeIdAttr}="${sanitize(entry.challenge.id)}">`,
247+
`<script ${attrs.challengeId}="${sanitize(entry.challenge.id)}">`,
297248
),
298249
)
299250
.join('\n')
@@ -327,9 +278,9 @@ export function render(options: {
327278
</section>
328279
${tabListHtml} ${panelsHtml}
329280
<script
330-
id="${dataId}"
281+
id="${ids.data}"
331282
type="application/json"
332-
${entries.length > 1 ? ` ${remainingAttr}="${entries.length}"` : ''}
283+
${entries.length > 1 ? ` ${attrs.remaining}="${entries.length}"` : ''}
333284
>
334285
${Json.stringify(dataMap satisfies Record<string, Data>).replace(/</g, '\\u003c')}
335286
</script>
@@ -410,12 +361,12 @@ function style(theme: {
410361
.${classNames.logo} {
411362
max-height: 1.75rem;
412363
}
413-
.${classNames.logoColorScheme('dark')} {
364+
.${logoColorScheme('dark')} {
414365
@media (prefers-color-scheme: light) {
415366
display: none;
416367
}
417368
}
418-
.${classNames.logoColorScheme('light')} {
369+
.${logoColorScheme('light')} {
419370
@media (prefers-color-scheme: dark) {
420371
display: none;
421372
}
@@ -497,7 +448,7 @@ function logo(value: Theme) {
497448
(entry) =>
498449
html`<img
499450
alt=""
500-
class="${classNames.logo} ${classNames.logoColorScheme(entry[0])}"
451+
class="${classNames.logo} ${logoColorScheme(entry[0])}"
501452
src="${sanitize(entry[1])}"
502453
/>`,
503454
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export const ids = {
2+
data: '__MPPX_DATA__',
3+
error: 'root_error',
4+
root: 'root',
5+
} as const
6+
7+
export const params = {
8+
serviceWorker: '__mppx_worker',
9+
tab: '__mppx_tab',
10+
} as const
11+
12+
export const attrs = {
13+
challengeId: 'data-mppx-challenge-id',
14+
remaining: 'data-remaining',
15+
} as const
16+
17+
export const classNames = {
18+
error: 'mppx-error',
19+
header: 'mppx-header',
20+
logo: 'mppx-logo',
21+
summary: 'mppx-summary',
22+
summaryAmount: 'mppx-summary-amount',
23+
summaryDescription: 'mppx-summary-description',
24+
summaryExpires: 'mppx-summary-expires',
25+
tab: 'mppx-tab',
26+
tabList: 'mppx-tablist',
27+
tabPanel: 'mppx-tabpanel',
28+
} as const

src/server/internal/html/serviceWorker.client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { serviceWorkerParam } from './config.js'
1+
import { params } from './constants.js'
22

33
export async function submitCredential(credential: string): Promise<void> {
44
const url = new URL(location.href)
5-
url.searchParams.set(serviceWorkerParam, '')
5+
url.searchParams.set(params.serviceWorker, '')
66

77
const registration = await navigator.serviceWorker.register(url.pathname + url.search)
88

0 commit comments

Comments
 (0)