Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,13 @@ The changelog is auto-generated from changesets during `changeset version`.
## Commands

```bash
pnpm build # Build with zile
pnpm check # Lint with oxlint + format with oxfmt
pnpm check:types # TypeScript type checking
pnpm test # Run tests with vitest
pnpm build # Build HTML with rolldown and library with zile
pnpm check # Lint with oxlint + format with oxfmt
pnpm check:types # TypeScript type checking
pnpm check:types:examples # Examples type checking
pnpm check:types:html # HTML type checking
pnpm test # Run tests with vitest
pnpm test:html # Run HTML tests with playwright
```

## Skills Reference
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@
"src": "./src/tempo/index.ts",
"default": "./dist/tempo/index.js"
},
"./html": {
"types": "./dist/Html.d.ts",
"src": "./src/Html.ts",
"default": "./dist/Html.js"
},
"./hono": {
"types": "./dist/middlewares/hono.d.ts",
"src": "./src/middlewares/hono.ts",
Expand Down
57 changes: 57 additions & 0 deletions src/Html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Json } from 'ox'

import type * as Method from './Method.js'
import { attrs, type Data, ids, vars } from './server/internal/html/config.js'
import { submitCredential } from './server/internal/html/serviceWorker.client.js'

export function init<
method extends Method.Method = Method.Method,
config extends Record<string, unknown> = {},
>(methodName: method['name']): Context<method, config> {
const element = document.getElementById(ids.data)!
const dataMap = Json.parse(element.textContent) as Record<string, Data<method, config>>

const remaining = element.getAttribute(attrs.remaining)
if (!remaining || Number(remaining) <= 1) element.remove()
else element.setAttribute(attrs.remaining, String(Number(remaining) - 1))

const script = document.currentScript
const challengeId = script?.getAttribute(attrs.challengeId)
const data = challengeId
? (script!.removeAttribute(attrs.challengeId), dataMap[challengeId]!)
: Object.values(dataMap).find((d) => d.challenge.method === methodName)!

return {
...data,
error(message?: string | null | undefined) {
if (!message) {
document.getElementById(ids.error)?.remove()
return
}
const existing = document.getElementById(ids.error)
if (existing) {
existing.textContent = message
return
}
const el = document.createElement('p')
el.id = ids.error
el.className = 'mppx-error'
el.role = 'alert'
el.textContent = message
document.getElementById(data.rootId)?.after(el)
},
root: document.getElementById(data.rootId)!,
submit: submitCredential,
vars,
}
}

export type Context<
method extends Method.Method = Method.Method,
config extends Record<string, unknown> = {},
> = Data<method, config> & {
error: (message?: string | null | undefined) => void
root: HTMLElement
submit: (credential: string) => Promise<void>
vars: typeof vars
}
4 changes: 2 additions & 2 deletions src/server/Mppx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,7 @@ export function compose(

return async (input: Request) => {
// Serve service worker for html-enabled compose
if (new URL(input.url).searchParams.has(Html.serviceWorkerParam)) {
if (new URL(input.url).searchParams.has(Html.params.serviceWorker)) {
const hasHtml = handlers.some((h) => (h as ConfiguredHandler)._internal?.html)
if (hasHtml)
return {
Expand Down Expand Up @@ -838,7 +838,7 @@ export function compose(
const entry = htmlEntries[i]!
dataMap[entry.challenge.id] = {
label: entry.handler._internal.name,
rootId: `${Html.rootId}-${i}`,
rootId: `${Html.ids.root}-${i}`,
formattedAmount: await entry.handler._internal.html!.formatAmount(
entry.challenge.request,
),
Expand Down
4 changes: 2 additions & 2 deletions src/server/Transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function http(): Http {
async respondChallenge(options) {
const { challenge, error, input } = options

if (options.html && new URL(input.url).searchParams.has(Html.serviceWorkerParam))
if (options.html && new URL(input.url).searchParams.has(Html.params.serviceWorker))
return new Response(serviceWorker, {
status: 200,
headers: {
Expand All @@ -153,7 +153,7 @@ export function http(): Http {
const dataMap = {
[challenge.id]: {
label: challenge.method,
rootId: Html.rootId,
rootId: Html.ids.root,
formattedAmount: amount,
config: options.html.config,
challenge,
Expand Down
49 changes: 25 additions & 24 deletions src/server/internal/html/compose.main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
const tablist = document.querySelector<HTMLElement>('.mppx-tablist')!
const summary = document.querySelector<HTMLElement>('.mppx-summary')!
const amountEl = summary.querySelector<HTMLElement>('.mppx-summary-amount')!
const param = '__mppx_tab'
import { classNames, params } from './constants.js'

const tablist = document.querySelector<HTMLElement>(`.${classNames.tabList}`)!
const summary = document.querySelector<HTMLElement>(`.${classNames.summary}`)!
const amount = summary.querySelector<HTMLElement>(`.${classNames.summaryAmount}`)!
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'))

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

function updateSummary(tab: HTMLElement) {
amountEl.textContent = tab.dataset.amount!
amount.textContent = tab.dataset.amount!

summary.querySelector('.mppx-summary-description')?.remove()
summary.querySelector(`.${classNames.summaryDescription}`)?.remove()
if (tab.dataset.description) {
const p = document.createElement('p')
p.className = 'mppx-summary-description'
p.className = classNames.summaryDescription
p.textContent = tab.dataset.description
amountEl.after(p)
amount.after(p)
}

summary.querySelector('.mppx-summary-expires')?.remove()
summary.querySelector(`.${classNames.summaryExpires}`)?.remove()
if (tab.dataset.expires) {
const p = document.createElement('p')
p.className = 'mppx-summary-expires'
p.className = classNames.summaryExpires
const date = new Date(tab.dataset.expires)
const time = document.createElement('time')
time.dateTime = date.toISOString()
Expand Down Expand Up @@ -55,33 +56,33 @@ function activate(tab: HTMLElement, updateUrl = true) {

if (updateUrl) {
const url = new URL(location.href)
url.searchParams.set(param, slugs[tabs.indexOf(tab)]!)
url.searchParams.set(params.tab, slugs[tabs.indexOf(tab)]!)
history.replaceState(null, '', url)
}
}

// Restore tab from URL on load
const initial = new URL(location.href).searchParams.get(param)
const initial = new URL(location.href).searchParams.get(params.tab)
if (initial !== null) {
const idx = slugs.indexOf(initial)
if (idx >= 0) activate(tabs[idx]!, false)
const index = slugs.indexOf(initial)
if (index >= 0) activate(tabs[index]!, false)
}

tablist.addEventListener('click', (e) => {
const tab = (e.target as HTMLElement).closest<HTMLElement>('[role="tab"]')
tablist.addEventListener('click', (event) => {
const tab = (event.target as HTMLElement).closest<HTMLElement>('[role="tab"]')
if (tab) activate(tab)
})

tablist.addEventListener('keydown', (e) => {
const idx = tabs.indexOf(e.target as HTMLElement)
if (idx < 0) return
tablist.addEventListener('keydown', (event) => {
const index = tabs.indexOf(event.target as HTMLElement)
if (index < 0) return
let next: HTMLElement | undefined
if (e.key === 'ArrowRight') next = tabs[(idx + 1) % tabs.length]
else if (e.key === 'ArrowLeft') next = tabs[(idx - 1 + tabs.length) % tabs.length]
else if (e.key === 'Home') next = tabs[0]
else if (e.key === 'End') next = tabs[tabs.length - 1]
if (event.key === 'ArrowRight') next = tabs[(index + 1) % tabs.length]
else if (event.key === 'ArrowLeft') next = tabs[(index - 1 + tabs.length) % tabs.length]
else if (event.key === 'Home') next = tabs[0]
else if (event.key === 'End') next = tabs[tabs.length - 1]
if (next) {
e.preventDefault()
event.preventDefault()
activate(next)
}
})
77 changes: 14 additions & 63 deletions src/server/internal/html/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,62 +27,13 @@ export type Data<
}
}

export const errorId = 'root_error'
export const rootId = 'root'
const dataId = '__MPPX_DATA__'
export { attrs, classNames, ids, params } from './constants.js'
import { attrs, classNames, ids } from './constants.js'

export const serviceWorkerParam = '__mppx_worker'

const challengeIdAttr = 'data-mppx-challenge-id'
const remainingAttr = 'data-remaining'

export function getData<
method extends Method.Method = Method.Method,
config extends Record<string, unknown> = {},
>(methodName: method['name']): Data<method, config> {
const el = document.getElementById(dataId)!
const map = Json.parse(el.textContent) as Record<string, Data<method, config>>
const remaining = el.getAttribute(remainingAttr)
if (!remaining || Number(remaining) <= 1) el.remove()
else el.setAttribute(remainingAttr, String(Number(remaining) - 1))
const script = document.currentScript
const challengeId = script?.getAttribute(challengeIdAttr)
if (challengeId) {
script!.removeAttribute(challengeIdAttr)
return map[challengeId]!
}
return Object.values(map).find((d) => d.challenge.method === methodName)!
}

export function showError(message: string) {
const existing = document.getElementById(errorId)
if (existing) {
existing.textContent = message
return
}
const el = document.createElement('p')
el.id = errorId
el.className = classNames.error
el.role = 'alert'
el.textContent = message
document.getElementById(rootId)?.after(el)
}

const classNames = {
error: 'mppx-error',
header: 'mppx-header',
logo: 'mppx-logo',
logoColorScheme: (colorScheme: string) =>
colorScheme === 'dark' || colorScheme === 'light'
? `${classNames.logo}--${colorScheme}`
: undefined,
summary: 'mppx-summary',
summaryAmount: 'mppx-summary-amount',
summaryDescription: 'mppx-summary-description',
summaryExpires: 'mppx-summary-expires',
tab: 'mppx-tab',
tabList: 'mppx-tablist',
tabPanel: 'mppx-tabpanel',
function logoColorScheme(colorScheme: string) {
return colorScheme === 'dark' || colorScheme === 'light'
? `${classNames.logo}--${colorScheme}`
: undefined
}

class CssVar {
Expand Down Expand Up @@ -282,18 +233,18 @@ export function render(options: {
id="mppx-panel-${i}"
${i !== 0 ? 'hidden' : ''}
>
<div id="${rootId}-${i}" aria-label="Payment form"></div>
<div id="${ids.root}-${i}" aria-label="Payment form"></div>
</div>`,
)
.join('')
: html`<div id="${rootId}" aria-label="Payment form"></div>`
: html`<div id="${ids.root}" aria-label="Payment form"></div>`

const contentScripts = hasTabs
? entries
.map((entry) =>
entry.content.replace(
'<script>',
`<script ${challengeIdAttr}="${sanitize(entry.challenge.id)}">`,
`<script ${attrs.challengeId}="${sanitize(entry.challenge.id)}">`,
),
)
.join('\n')
Expand Down Expand Up @@ -327,9 +278,9 @@ export function render(options: {
</section>
${tabListHtml} ${panelsHtml}
<script
id="${dataId}"
id="${ids.data}"
type="application/json"
${entries.length > 1 ? ` ${remainingAttr}="${entries.length}"` : ''}
${entries.length > 1 ? ` ${attrs.remaining}="${entries.length}"` : ''}
>
${Json.stringify(dataMap satisfies Record<string, Data>).replace(/</g, '\\u003c')}
</script>
Expand Down Expand Up @@ -410,12 +361,12 @@ function style(theme: {
.${classNames.logo} {
max-height: 1.75rem;
}
.${classNames.logoColorScheme('dark')} {
.${logoColorScheme('dark')} {
@media (prefers-color-scheme: light) {
display: none;
}
}
.${classNames.logoColorScheme('light')} {
.${logoColorScheme('light')} {
@media (prefers-color-scheme: dark) {
display: none;
}
Expand Down Expand Up @@ -497,7 +448,7 @@ function logo(value: Theme) {
(entry) =>
html`<img
alt=""
class="${classNames.logo} ${classNames.logoColorScheme(entry[0])}"
class="${classNames.logo} ${logoColorScheme(entry[0])}"
src="${sanitize(entry[1])}"
/>`,
)
Expand Down
28 changes: 28 additions & 0 deletions src/server/internal/html/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export const ids = {
data: '__MPPX_DATA__',
error: 'root_error',
root: 'root',
} as const

export const params = {
serviceWorker: '__mppx_worker',
tab: '__mppx_tab',
} as const

export const attrs = {
challengeId: 'data-mppx-challenge-id',
remaining: 'data-remaining',
} as const

export const classNames = {
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',
} as const
4 changes: 2 additions & 2 deletions src/server/internal/html/serviceWorker.client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { serviceWorkerParam } from './config.js'
import { params } from './constants.js'

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

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

Expand Down
Loading
Loading