diff --git a/AGENTS.md b/AGENTS.md index dc17483d..eff5ebae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/package.json b/package.json index 39fbd8fa..3cc47763 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/Html.ts b/src/Html.ts new file mode 100644 index 00000000..6302b1d3 --- /dev/null +++ b/src/Html.ts @@ -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 = {}, +>(methodName: method['name']): Context { + const element = document.getElementById(ids.data)! + const dataMap = Json.parse(element.textContent) as Record> + + 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 = {}, +> = Data & { + error: (message?: string | null | undefined) => void + root: HTMLElement + submit: (credential: string) => Promise + vars: typeof vars +} diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 526fbb1f..b09c7c0b 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -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 { @@ -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, ), diff --git a/src/server/Transport.ts b/src/server/Transport.ts index fc961a22..fcf9790b 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -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: { @@ -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, diff --git a/src/server/internal/html/compose.main.ts b/src/server/internal/html/compose.main.ts index bf6dcd64..fe924b06 100644 --- a/src/server/internal/html/compose.main.ts +++ b/src/server/internal/html/compose.main.ts @@ -1,7 +1,8 @@ -const tablist = document.querySelector('.mppx-tablist')! -const summary = document.querySelector('.mppx-summary')! -const amountEl = summary.querySelector('.mppx-summary-amount')! -const param = '__mppx_tab' +import { classNames, params } from './constants.js' + +const tablist = document.querySelector(`.${classNames.tabList}`)! +const summary = document.querySelector(`.${classNames.summary}`)! +const amount = summary.querySelector(`.${classNames.summaryAmount}`)! const tabs = Array.from(tablist.querySelectorAll('[role="tab"]')) // Generate unique slugs: tempo, stripe, stripe-2 @@ -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() @@ -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('[role="tab"]') +tablist.addEventListener('click', (event) => { + const tab = (event.target as HTMLElement).closest('[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) } }) diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index 46d484f0..8c05d07b 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -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 = {}, ->(methodName: method['name']): Data { - const el = document.getElementById(dataId)! - const map = Json.parse(el.textContent) as Record> - 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 { @@ -282,18 +233,18 @@ export function render(options: { id="mppx-panel-${i}" ${i !== 0 ? 'hidden' : ''} > -
+
`, ) .join('') - : html`
` + : html`
` const contentScripts = hasTabs ? entries .map((entry) => entry.content.replace( ' @@ -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; } @@ -497,7 +448,7 @@ function logo(value: Theme) { (entry) => html``, ) diff --git a/src/server/internal/html/constants.ts b/src/server/internal/html/constants.ts new file mode 100644 index 00000000..85515c02 --- /dev/null +++ b/src/server/internal/html/constants.ts @@ -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 diff --git a/src/server/internal/html/serviceWorker.client.ts b/src/server/internal/html/serviceWorker.client.ts index f94de3c1..4b0cf1bf 100644 --- a/src/server/internal/html/serviceWorker.client.ts +++ b/src/server/internal/html/serviceWorker.client.ts @@ -1,8 +1,8 @@ -import { serviceWorkerParam } from './config.js' +import { params } from './constants.js' export async function submitCredential(credential: string): Promise { 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) diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index 2c5d4b0f..85d7074c 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -2,15 +2,13 @@ import type { Appearance } from '@stripe/stripe-js' import { loadStripe } from '@stripe/stripe-js/pure' import { stripe } from '../../../../client/index.js' -import * as Html from '../../../../server/internal/html/config.js' -import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js' +import * as Html from '../../../../Html.js' +import { mergeDefined } from '../../../../server/internal/html/config.js' import type { charge as chargeClient } from '../../../../stripe/client/Charge.js' import type { charge } from '../../../../stripe/server/Charge.js' import type * as Methods from '../../../Methods.js' -const data = Html.getData>('stripe') - -const root = document.getElementById(data.rootId)! +const c = Html.init>('stripe') const css = String.raw const style = document.createElement('style') @@ -18,15 +16,15 @@ style.textContent = css` form { display: flex; flex-direction: column; - gap: calc(${Html.vars.spacingUnit} * 8); + gap: calc(${c.vars.spacingUnit} * 8); } button { - background: ${Html.vars.accent}; - border-radius: ${Html.vars.radius}; - color: ${Html.vars.background}; + background: ${c.vars.accent}; + border-radius: ${c.vars.radius}; + color: ${c.vars.background}; cursor: pointer; font-weight: 500; - padding: calc(${Html.vars.spacingUnit} * 4) calc(${Html.vars.spacingUnit} * 8); + padding: calc(${c.vars.spacingUnit} * 4) calc(${c.vars.spacingUnit} * 8); width: 100%; } button:hover:not(:disabled) { @@ -37,24 +35,24 @@ style.textContent = css` opacity: 0.5; } ` -root.append(style) +c.root.append(style) ;(async () => { if (import.meta.env.MODE === 'test') { const button = document.createElement('button') - button.textContent = data.text.pay - root.appendChild(button) + button.textContent = c.text.pay + c.root.appendChild(button) button.onclick = async () => { try { button.disabled = true const method = stripe({ createToken })[0] const credential = await method.createCredential({ - challenge: data.challenge, + challenge: c.challenge, context: { paymentMethod: 'pm_card_visa' }, }) - await submitCredential(credential) - } catch (e) { - Html.showError(e instanceof Error ? e.message : 'Payment failed') + await c.submit(credential) + } catch (error) { + c.error(error instanceof Error ? error.message : 'Payment failed') } finally { button.disabled = false } @@ -62,15 +60,15 @@ root.append(style) return } - const stripeJs = await loadStripe(data.config.publishableKey) + const stripeJs = await loadStripe(c.config.publishableKey) if (!stripeJs) throw new Error('Failed to loadStripe') const darkQuery = window.matchMedia('(prefers-color-scheme: dark)') const getAppearance = () => { const theme = (() => { - if (data.config.elements?.options?.appearance?.theme) - return data.config.elements?.options?.appearance?.theme - switch (data.theme.colorScheme) { + if (c.config.elements?.options?.appearance?.theme) + return c.config.elements?.options?.appearance?.theme + switch (c.theme.colorScheme) { case 'light dark': return (darkQuery.matches ? 'night' : 'stripe') as 'night' | 'stripe' case 'light': @@ -80,34 +78,34 @@ root.append(style) } })() const resolvedColorSchemeIndex = darkQuery.matches ? 1 : 0 - return Html.mergeDefined( + return mergeDefined( { disableAnimations: true, theme, variables: { - borderRadius: data.theme.radius, - colorBackground: data.theme.surface[resolvedColorSchemeIndex], - colorDanger: data.theme.negative[resolvedColorSchemeIndex], - colorPrimary: data.theme.accent[resolvedColorSchemeIndex], - colorText: data.theme.foreground[resolvedColorSchemeIndex], - colorTextSecondary: data.theme.muted[resolvedColorSchemeIndex], - fontSizeBase: data.theme.fontSizeBase, - fontFamily: data.theme.fontFamily, - spacingUnit: data.theme.spacingUnit, + borderRadius: c.theme.radius, + colorBackground: c.theme.surface[resolvedColorSchemeIndex], + colorDanger: c.theme.negative[resolvedColorSchemeIndex], + colorPrimary: c.theme.accent[resolvedColorSchemeIndex], + colorText: c.theme.foreground[resolvedColorSchemeIndex], + colorTextSecondary: c.theme.muted[resolvedColorSchemeIndex], + fontSizeBase: c.theme.fontSizeBase, + fontFamily: c.theme.fontFamily, + spacingUnit: c.theme.spacingUnit, }, } satisfies Appearance, - (data.config.elements?.options?.appearance as never) ?? {}, + (c.config.elements?.options?.appearance as never) ?? {}, ) } const elements = stripeJs.elements({ appearance: getAppearance(), - ...data.config.elements?.options, - amount: Number(data.challenge.request.amount), - currency: data.challenge.request.currency, + ...c.config.elements?.options, + amount: Number(c.challenge.request.amount), + currency: c.challenge.request.currency, mode: 'payment', paymentMethodCreation: 'manual', - paymentMethodTypes: data.challenge.request.methodDetails.paymentMethodTypes, + paymentMethodTypes: c.challenge.request.methodDetails.paymentMethodTypes, }) darkQuery.addEventListener('change', () => { @@ -115,34 +113,34 @@ root.append(style) }) const form = document.createElement('form') - elements.create('payment', data.config.elements?.paymentOptions).mount(form) - root.appendChild(form) + elements.create('payment', c.config.elements?.paymentOptions).mount(form) + c.root.appendChild(form) const button = document.createElement('button') - button.textContent = data.text.pay + button.textContent = c.text.pay button.type = 'submit' form.appendChild(button) form.onsubmit = async (event) => { event.preventDefault() - document.getElementById(Html.errorId)?.remove() + c.error() button.disabled = true try { await elements.submit() const { paymentMethod, error: stripeError } = await stripeJs.createPaymentMethod({ - ...data.config.elements?.createPaymentMethodOptions, + ...c.config.elements?.createPaymentMethodOptions, elements, }) if (stripeError || !paymentMethod) throw stripeError ?? new Error('Failed to create payment method') const method = stripe({ client: stripeJs, createToken })[0] const credential = await method.createCredential({ - challenge: data.challenge, + challenge: c.challenge, context: { paymentMethod: paymentMethod.id }, }) - await submitCredential(credential) - } catch (e) { - Html.showError(e instanceof Error ? e.message : 'Payment failed') + await c.submit(credential) + } catch (error) { + c.error(error instanceof Error ? error.message : 'Payment failed') } finally { button.disabled = false } @@ -150,7 +148,7 @@ root.append(style) })() async function createToken(opts: chargeClient.OnChallengeParameters) { - const createTokenUrl = new URL(data.config.createTokenUrl, location.origin) + const createTokenUrl = new URL(c.config.createTokenUrl, location.origin) if (createTokenUrl.origin !== location.origin) throw new Error('createTokenUrl must be same-origin') const res = await fetch(createTokenUrl, { diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 7dff0810..5ed60860 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -3,13 +3,10 @@ import { createClient, custom, http } from 'viem' import { tempoModerato, tempoLocalnet } from 'viem/chains' import { tempo } from '../../../../client/index.js' -import * as Html from '../../../../server/internal/html/config.js' -import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js' +import * as Html from '../../../../Html.js' import type * as Methods from '../../../Methods.js' -const data = Html.getData('tempo') - -const root = document.getElementById(data.rootId)! +const c = Html.init('tempo') const css = String.raw const style = document.createElement('style') @@ -17,15 +14,15 @@ style.textContent = css` form { display: flex; flex-direction: column; - gap: calc(${Html.vars.spacingUnit} * 8); + gap: calc(${c.vars.spacingUnit} * 8); } button { - background: ${Html.vars.accent}; - border-radius: ${Html.vars.radius}; - color: ${Html.vars.background}; + background: ${c.vars.accent}; + border-radius: ${c.vars.radius}; + color: ${c.vars.background}; cursor: pointer; font-weight: 500; - padding: calc(${Html.vars.spacingUnit} * 4) calc(${Html.vars.spacingUnit} * 8); + padding: calc(${c.vars.spacingUnit} * 4) calc(${c.vars.spacingUnit} * 8); width: 100%; } button:hover:not(:disabled) { @@ -44,7 +41,7 @@ style.textContent = css` width: auto; } ` -root.append(style) +c.root.append(style) const provider = Provider.create({ // Dead code eliminated from production bundle (including top-level imports) @@ -58,7 +55,7 @@ const provider = Provider.create({ const account = Account.fromSecp256k1(privateKey) const client = createClient({ chain: [tempoModerato, tempoLocalnet].find( - (x) => x.id === data.challenge.request.methodDetails?.chainId, + (x) => x.id === c.challenge.request.methodDetails?.chainId, ), transport: http(), }) @@ -71,8 +68,8 @@ const provider = Provider.create({ } : {}), testnet: - data.challenge.request.methodDetails?.chainId === tempoModerato.id || - data.challenge.request.methodDetails?.chainId === tempoLocalnet.id, + c.challenge.request.methodDetails?.chainId === tempoModerato.id || + c.challenge.request.methodDetails?.chainId === tempoLocalnet.id, }) const button = document.createElement('button') @@ -80,7 +77,7 @@ button.innerHTML = 'Continue with ' button.onclick = async () => { try { - document.getElementById(Html.errorId)?.remove() + c.error() button.disabled = true const account = await (async () => { @@ -92,7 +89,7 @@ button.onclick = async () => { const method = tempo({ account, getClient(opts) { - const chainId = opts.chainId ?? data.challenge.request.methodDetails?.chainId + const chainId = opts.chainId ?? c.challenge.request.methodDetails?.chainId const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find( (x) => x.id === chainId, ) @@ -100,13 +97,13 @@ button.onclick = async () => { }, })[0] - const credential = await method.createCredential({ challenge: data.challenge, context: {} }) - await submitCredential(credential) + const credential = await method.createCredential({ challenge: c.challenge, context: {} }) + await c.submit(credential) } catch (e) { const message = e instanceof Error && 'shortMessage' in e ? (e as any).shortMessage : undefined - Html.showError(message ?? (e instanceof Error ? e.message : 'Payment failed')) + c.error(message ?? (e instanceof Error ? e.message : 'Payment failed')) } finally { button.disabled = false } } -root.appendChild(button) +c.root.appendChild(button)