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,5 +1,8 @@
1
+ import { Json } from 'ox'
2
+
1
3
  import type * as Challenge from '../../../Challenge.js'
2
4
  import type * as Method from '../../../Method.js'
5
+ import { tabScript } from './compose.main.gen.js'
3
6
 
4
7
  export type Options = {
5
8
  config: Record<string, unknown>
@@ -13,6 +16,9 @@ export type Data<
13
16
  method extends Method.Method = Method.Method,
14
17
  config extends Record<string, unknown> = {},
15
18
  > = {
19
+ label: string
20
+ rootId: string
21
+ formattedAmount: string
16
22
  config: config
17
23
  challenge: Challenge.FromMethods<[method]>
18
24
  text: { [k in keyof Text]-?: NonNullable<Text[k]> }
@@ -21,45 +27,15 @@ export type Data<
21
27
  }
22
28
  }
23
29
 
24
- export const dataId = '__MPPX_DATA__'
25
-
26
- export const errorId = 'root_error'
27
-
28
- export const rootId = 'root'
30
+ export { attrs, classNames, ids, params } from './constants.js'
31
+ import { attrs, classNames, ids } from './constants.js'
29
32
 
30
- export const serviceWorkerParam = '__mppx_worker'
31
-
32
- export const classNames = {
33
- error: 'mppx-error',
34
- header: 'mppx-header',
35
- logo: 'mppx-logo',
36
- logoColorScheme: (colorScheme: string) =>
37
- colorScheme === 'dark' || colorScheme === 'light'
38
- ? `${classNames.logo}--${colorScheme}`
39
- : undefined,
40
- summary: 'mppx-summary',
41
- summaryAmount: 'mppx-summary-amount',
42
- summaryDescription: 'mppx-summary-description',
43
- summaryExpires: 'mppx-summary-expires',
44
- }
45
-
46
- export function sanitize(str: string): string {
47
- return str
48
- .replace(/&/g, '&amp;')
49
- .replace(/</g, '&lt;')
50
- .replace(/>/g, '&gt;')
51
- .replace(/"/g, '&quot;')
52
- .replace(/'/g, '&#39;')
53
- }
54
-
55
- export function sanitizeRecord<type extends Record<string, string>>(record: type): type {
56
- return Object.fromEntries(
57
- Object.entries(record).map(([key, value]) => [key, sanitize(value)]),
58
- ) as type
33
+ function logoColorScheme(colorScheme: string) {
34
+ return colorScheme === 'dark' || colorScheme === 'light'
35
+ ? `${classNames.logo}--${colorScheme}`
36
+ : undefined
59
37
  }
60
38
 
61
- export const html = String.raw
62
-
63
39
  class CssVar {
64
40
  readonly name: string
65
41
  constructor(token: string) {
@@ -85,17 +61,241 @@ export const vars = {
85
61
  spacingUnit: new CssVar('spacing-unit'),
86
62
  } as const
87
63
 
88
- export function font(theme: Theme) {
89
- if (!theme.fontUrl) return ''
90
- return html`<link
91
- rel="preconnect"
92
- href="${sanitize(new URL(theme.fontUrl).origin)}"
93
- crossorigin
94
- />
95
- <link rel="stylesheet" href="${sanitize(theme.fontUrl)}" />`
64
+ export const defaultText = {
65
+ expires: 'Expires at',
66
+ pay: 'Pay',
67
+ paymentRequired: 'Payment Required',
68
+ title: 'Payment Required',
69
+ } as const satisfies Required<Text>
70
+
71
+ export type Text = {
72
+ /** Prefix for the expiry line. @default 'Expires at' */
73
+ expires?: string | undefined
74
+ /** Pay button label. @default 'Pay' */
75
+ pay?: string | undefined
76
+ /** Badge label. @default 'Payment Required' */
77
+ paymentRequired?: string | undefined
78
+ /** Page title. @default text.paymentRequired */
79
+ title?: string | undefined
80
+ }
81
+
82
+ export const defaultTheme = {
83
+ colorScheme: 'light dark',
84
+ fontFamily: 'system-ui, -apple-system, sans-serif',
85
+ fontSizeBase: '16px',
86
+ radius: '6px',
87
+ spacingUnit: '2px',
88
+
89
+ accent: ['#171717', '#ededed'],
90
+ background: ['#ffffff', '#0a0a0a'],
91
+ border: ['#e5e5e5', '#2e2e2e'],
92
+ foreground: ['#0a0a0a', '#ededed'],
93
+ muted: ['#666666', '#a1a1a1'],
94
+ negative: ['#e5484d', '#e5484d'],
95
+ positive: ['#30a46c', '#30a46c'],
96
+ surface: ['#f5f5f5', '#1a1a1a'],
97
+ } as const satisfies Required<Omit<Theme, 'favicon' | 'fontUrl' | 'logo'>>
98
+
99
+ export type Theme = {
100
+ /** Color scheme. @default 'light dark' */
101
+ colorScheme?: 'light' | 'dark' | 'light dark' | undefined
102
+ /** Font family. @default 'system-ui, -apple-system, sans-serif' */
103
+ fontFamily?: string | undefined
104
+ /** Base font size. @default '16px' */
105
+ fontSizeBase?: string | undefined
106
+ /** Font URL to inject (e.g. Google Fonts `<link>`). */
107
+ fontUrl?: string | undefined
108
+ /** Favicon URL. Light/dark variants supported. Falls back to host's favicon via Google S2 service. */
109
+ favicon?: string | { light: string; dark: string } | undefined
110
+ /** Logo URL shown in header. Light/dark variants supported. */
111
+ logo?: string | { light: string; dark: string } | undefined
112
+ /** Border radius. @default '6px' */
113
+ radius?: string | undefined
114
+ /** The base spacing unit that all other spacing is derived from. Increase or decrease this value to make your layout more or less spacious. @default '2px' */
115
+ spacingUnit?: string | undefined
116
+
117
+ /** Accent color (buttons, links). @default ['#171717', '#ededed'] */
118
+ accent?: LightDark | undefined
119
+ /** Page background. @default ['#ffffff', '#0a0a0a'] */
120
+ background?: LightDark | undefined
121
+ /** Border color. @default ['#e5e5e5', '#2e2e2e'] */
122
+ border?: LightDark | undefined
123
+ /** Primary text/content color. @default ['#0a0a0a', '#ededed'] */
124
+ foreground?: LightDark | undefined
125
+ /** Secondary/muted text. @default ['#666666', '#a1a1a1'] */
126
+ muted?: LightDark | undefined
127
+ /** Error/danger color. @default ['#e5484d', '#e5484d'] */
128
+ negative?: LightDark | undefined
129
+ /** Success color. @default ['#30a46c', '#30a46c'] */
130
+ positive?: LightDark | undefined
131
+ /** Input/card surface. @default ['#f5f5f5', '#1a1a1a'] */
132
+ surface?: LightDark | undefined
96
133
  }
97
134
 
98
- export function style(theme: {
135
+ export type Config = {
136
+ text?: Text | undefined
137
+ theme?: Theme | undefined
138
+ }
139
+
140
+ type LightDark = string | readonly [light: string, dark: string]
141
+
142
+ function resolveColor(
143
+ value: Theme[(typeof colorTokens)[number]] | undefined,
144
+ fallback: readonly [string, string],
145
+ ): readonly [light: string, dark: string] {
146
+ if (!value) return fallback
147
+ if (typeof value === 'string') return [value, value]
148
+ return value
149
+ }
150
+
151
+ const colorTokens = [
152
+ 'accent',
153
+ 'negative',
154
+ 'positive',
155
+ 'background',
156
+ 'foreground',
157
+ 'muted',
158
+ 'surface',
159
+ 'border',
160
+ ] as const satisfies readonly (keyof typeof defaultTheme)[]
161
+
162
+ export function resolveOptions(options: Options): {
163
+ theme: ResolvedTheme
164
+ text: ResolvedText
165
+ } {
166
+ const theme = mergeDefined(
167
+ {
168
+ favicon: undefined as Theme['favicon'],
169
+ fontUrl: undefined as Theme['fontUrl'],
170
+ logo: undefined as Theme['logo'],
171
+ ...defaultTheme,
172
+ },
173
+ (options.theme as never) ?? {},
174
+ )
175
+ const text = sanitizeRecord(mergeDefined(defaultText, (options.text as never) ?? {}))
176
+ return { theme, text }
177
+ }
178
+
179
+ type ResolvedTheme = {
180
+ [k in keyof Omit<Theme, 'favicon' | 'fontUrl' | 'logo'>]-?: NonNullable<Theme[k]>
181
+ } & Pick<Theme, 'favicon' | 'fontUrl' | 'logo'>
182
+ type ResolvedText = { [k in keyof Text]-?: NonNullable<Text[k]> }
183
+
184
+ const html = String.raw
185
+
186
+ export function render(options: {
187
+ entries: readonly {
188
+ challenge: Challenge.Challenge
189
+ content: string
190
+ }[]
191
+ dataMap: Record<string, Data>
192
+ formattedAmount: string
193
+ /** Whether to render panel wrappers around each entry. @default entries.length > 1 */
194
+ panels?: boolean | undefined
195
+ text: ResolvedText
196
+ theme: ResolvedTheme
197
+ }): string {
198
+ const { entries, dataMap, formattedAmount, text, theme } = options
199
+ const firstChallenge = entries[0]!.challenge
200
+ const hasTabs = entries.length > 1
201
+ const hasPanels = options.panels ?? hasTabs
202
+ const dataValues = Object.values(dataMap)
203
+
204
+ const tabListHtml = hasTabs
205
+ ? html`<nav class="${classNames.tabList}" role="tablist" aria-label="Payment methods">
206
+ ${dataValues
207
+ .map(
208
+ (data, i) =>
209
+ html`<button
210
+ class="${classNames.tab}"
211
+ role="tab"
212
+ id="mppx-tab-${i}"
213
+ aria-selected="${i === 0 ? 'true' : 'false'}"
214
+ aria-controls="mppx-panel-${i}"
215
+ ${i !== 0 ? 'tabindex="-1"' : ''}
216
+ data-amount="${sanitize(data.formattedAmount)}"
217
+ ${data.challenge.description
218
+ ? `data-description="${sanitize(data.challenge.description)}"`
219
+ : ''}
220
+ ${data.challenge.expires
221
+ ? `data-expires="${sanitize(data.challenge.expires)}"`
222
+ : ''}
223
+ ${data.challenge.expires ? `data-expires-label="${sanitize(text.expires)}"` : ''}
224
+ >
225
+ ${sanitize(data.label)}
226
+ </button>`,
227
+ )
228
+ .join('')}
229
+ </nav>`
230
+ : ''
231
+
232
+ const panelsHtml = hasPanels
233
+ ? entries
234
+ .map(
235
+ (_entry, i) =>
236
+ html`<div
237
+ ${hasTabs ? `role="tabpanel" aria-labelledby="mppx-tab-${i}"` : ''}
238
+ id="mppx-panel-${i}"
239
+ ${i !== 0 ? 'hidden' : ''}
240
+ >
241
+ <div id="${ids.root}-${i}" aria-label="Payment form"></div>
242
+ </div>`,
243
+ )
244
+ .join('')
245
+ : html`<div id="${ids.root}" aria-label="Payment form"></div>`
246
+
247
+ const contentScripts = hasTabs
248
+ ? entries
249
+ .map((entry) =>
250
+ entry.content.replace(
251
+ '<script>',
252
+ `<script ${attrs.challengeId}="${sanitize(entry.challenge.id)}">`,
253
+ ),
254
+ )
255
+ .join('\n')
256
+ : entries[0]!.content
257
+
258
+ return html`<!doctype html>
259
+ <html lang="en">
260
+ <head>
261
+ <meta charset="UTF-8" />
262
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
263
+ <meta name="robots" content="noindex" />
264
+ <meta name="color-scheme" content="${theme.colorScheme}" />
265
+ <title>${text.title}</title>
266
+ ${preflight} ${favicon(theme, firstChallenge.realm)} ${font(theme)} ${style(theme)}
267
+ ${hasTabs ? tabStyle() : ''}
268
+ </head>
269
+ <body>
270
+ <main>
271
+ <header class="${classNames.header}">
272
+ ${logo(theme)}
273
+ <span>${text.paymentRequired}</span>
274
+ </header>
275
+ <section class="${classNames.summary}" aria-label="Payment summary">
276
+ <h1 class="${classNames.summaryAmount}">${sanitize(formattedAmount)}</h1>
277
+ ${firstChallenge.description
278
+ ? `<p class="${classNames.summaryDescription}">${sanitize(firstChallenge.description)}</p>`
279
+ : ''}
280
+ ${firstChallenge.expires
281
+ ? `<p class="${classNames.summaryExpires}">${text.expires} <time datetime="${new Date(firstChallenge.expires).toISOString()}">${new Date(firstChallenge.expires).toLocaleString()}</time></p>`
282
+ : ''}
283
+ </section>
284
+ ${tabListHtml} ${panelsHtml}
285
+ <script
286
+ id="${ids.data}"
287
+ type="application/json"
288
+ ${entries.length > 1 ? ` ${attrs.remaining}="${entries.length}"` : ''}
289
+ >
290
+ ${Json.stringify(dataMap satisfies Record<string, Data>).replace(/</g, '\\u003c')}
291
+ </script>
292
+ ${contentScripts} ${hasTabs ? tabScript : ''}
293
+ </main>
294
+ </body>
295
+ </html>`
296
+ }
297
+
298
+ function style(theme: {
99
299
  [k in keyof Omit<Theme, 'favicon' | 'fontUrl' | 'logo'>]-?: NonNullable<Theme[k]>
100
300
  }) {
101
301
  const colors = Object.fromEntries(
@@ -116,7 +316,6 @@ export function style(theme: {
116
316
  : ''
117
317
  return html`
118
318
  <style>
119
- ${reset}
120
319
  :root {
121
320
  color-scheme: ${theme.colorScheme};
122
321
  ${vars.fontFamily.name}: ${theme.fontFamily};
@@ -167,12 +366,12 @@ export function style(theme: {
167
366
  .${classNames.logo} {
168
367
  max-height: 1.75rem;
169
368
  }
170
- .${classNames.logoColorScheme('dark')} {
369
+ .${logoColorScheme('dark')} {
171
370
  @media (prefers-color-scheme: light) {
172
371
  display: none;
173
372
  }
174
373
  }
175
- .${classNames.logoColorScheme('light')} {
374
+ .${logoColorScheme('light')} {
176
375
  @media (prefers-color-scheme: dark) {
177
376
  display: none;
178
377
  }
@@ -208,21 +407,7 @@ export function style(theme: {
208
407
  `
209
408
  }
210
409
 
211
- export function showError(message: string) {
212
- const existing = document.getElementById(errorId)
213
- if (existing) {
214
- existing.textContent = message
215
- return
216
- }
217
- const el = document.createElement('p')
218
- el.id = errorId
219
- el.className = classNames.error
220
- el.role = 'alert'
221
- el.textContent = message
222
- document.getElementById(rootId)?.after(el)
223
- }
224
-
225
- export function favicon(theme: Theme, realm: string) {
410
+ function favicon(theme: Theme, realm: string) {
226
411
  if (typeof theme.favicon === 'string')
227
412
  return html`<link rel="icon" href="${sanitize(theme.favicon)}" />`
228
413
  if (typeof theme.favicon === 'object') {
@@ -249,7 +434,17 @@ export function favicon(theme: Theme, realm: string) {
249
434
  }
250
435
  }
251
436
 
252
- export function logo(value: Theme) {
437
+ function font(theme: Theme) {
438
+ if (!theme.fontUrl) return ''
439
+ return html`<link
440
+ rel="preconnect"
441
+ href="${sanitize(new URL(theme.fontUrl).origin)}"
442
+ crossorigin
443
+ />
444
+ <link rel="stylesheet" href="${sanitize(theme.fontUrl)}" />`
445
+ }
446
+
447
+ function logo(value: Theme) {
253
448
  if (typeof value.logo === 'undefined') return ''
254
449
  if (typeof value.logo === 'string')
255
450
  return html`<img alt="" class="${classNames.logo}" src="${sanitize(value.logo)}" />`
@@ -258,114 +453,193 @@ export function logo(value: Theme) {
258
453
  (entry) =>
259
454
  html`<img
260
455
  alt=""
261
- class="${classNames.logo} ${classNames.logoColorScheme(entry[0])}"
456
+ class="${classNames.logo} ${logoColorScheme(entry[0])}"
262
457
  src="${sanitize(entry[1])}"
263
458
  />`,
264
459
  )
265
460
  .join('\n')
266
461
  }
267
462
 
268
- export type Text = {
269
- /** Prefix for the expiry line. @default 'Expires at' */
270
- expires?: string | undefined
271
- /** Pay button label. @default 'Pay' */
272
- pay?: string | undefined
273
- /** Badge label. @default 'Payment Required' */
274
- paymentRequired?: string | undefined
275
- /** Page title. @default text.paymentRequired */
276
- title?: string | undefined
463
+ function tabStyle() {
464
+ return html`
465
+ <style>
466
+ .${classNames.tabList} {
467
+ display: flex;
468
+ gap: calc(${vars.spacingUnit} * 2);
469
+ border-bottom: 1px solid ${vars.border};
470
+ }
471
+ .${classNames.tab} {
472
+ background: none !important;
473
+ border: none !important;
474
+ border-bottom: 1px solid transparent !important;
475
+ border-radius: 0 !important;
476
+ color: ${vars.muted} !important;
477
+ cursor: pointer;
478
+ font-size: 0.875rem;
479
+ font-weight: 500;
480
+ margin-bottom: -1px !important;
481
+ padding: calc(${vars.spacingUnit} * 2) calc(${vars.spacingUnit} * 4) !important;
482
+ text-transform: capitalize;
483
+ width: auto !important;
484
+ }
485
+ .${classNames.tab}[aria-selected='true'] {
486
+ border-bottom-color: ${vars.foreground} !important;
487
+ color: ${vars.foreground} !important;
488
+ }
489
+ .${classNames.tab}:hover:not([aria-selected='true']) {
490
+ color: ${vars.foreground} !important;
491
+ }
492
+ .${classNames.tabPanel}[hidden] {
493
+ display: none;
494
+ }
495
+ </style>
496
+ `
277
497
  }
278
498
 
279
- export const defaultText = {
280
- expires: 'Expires at',
281
- pay: 'Pay',
282
- paymentRequired: 'Payment Required',
283
- title: 'Payment Required',
284
- } as const satisfies Required<Text>
285
-
286
- export type Theme = {
287
- /** Color scheme. @default 'light dark' */
288
- colorScheme?: 'light' | 'dark' | 'light dark' | undefined
289
- /** Font family. @default 'system-ui, -apple-system, sans-serif' */
290
- fontFamily?: string | undefined
291
- /** Base font size. @default '16px' */
292
- fontSizeBase?: string | undefined
293
- /** Font URL to inject (e.g. Google Fonts `<link>`). */
294
- fontUrl?: string | undefined
295
- /** Favicon URL. Light/dark variants supported. Falls back to host's favicon via Google S2 service. */
296
- favicon?: string | { light: string; dark: string } | undefined
297
- /** Logo URL shown in header. Light/dark variants supported. */
298
- logo?: string | { light: string; dark: string } | undefined
299
- /** Border radius. @default '6px' */
300
- radius?: string | undefined
301
- /** The base spacing unit that all other spacing is derived from. Increase or decrease this value to make your layout more or less spacious. @default '2px' */
302
- spacingUnit?: string | undefined
499
+ // Slimmed down Tailwind preflight
500
+ // https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/preflight.css
501
+ const preflight = html`<style>
502
+ *,
503
+ ::after,
504
+ ::before,
505
+ ::backdrop,
506
+ ::file-selector-button {
507
+ box-sizing: border-box;
508
+ margin: 0;
509
+ padding: 0;
510
+ border: 0 solid;
511
+ border-color: ${vars.border};
512
+ }
513
+ html,
514
+ :host {
515
+ line-height: 1.5;
516
+ -webkit-text-size-adjust: 100%;
517
+ tab-size: 4;
518
+ -webkit-tap-highlight-color: transparent;
519
+ }
520
+ h1,
521
+ h2,
522
+ h3,
523
+ h4,
524
+ h5,
525
+ h6 {
526
+ font-size: inherit;
527
+ font-weight: inherit;
528
+ }
529
+ a {
530
+ color: inherit;
531
+ -webkit-text-decoration: inherit;
532
+ text-decoration: inherit;
533
+ }
534
+ b,
535
+ strong {
536
+ font-weight: bolder;
537
+ }
538
+ code,
539
+ kbd,
540
+ samp,
541
+ pre {
542
+ font-family:
543
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
544
+ monospace;
545
+ font-size: 1em;
546
+ }
547
+ small {
548
+ font-size: 80%;
549
+ }
550
+ ol,
551
+ ul,
552
+ menu {
553
+ list-style: none;
554
+ }
555
+ img,
556
+ svg,
557
+ video,
558
+ canvas,
559
+ audio,
560
+ iframe,
561
+ embed,
562
+ object {
563
+ display: block;
564
+ vertical-align: middle;
565
+ }
566
+ img,
567
+ video {
568
+ max-width: 100%;
569
+ height: auto;
570
+ }
571
+ button,
572
+ input,
573
+ select,
574
+ optgroup,
575
+ textarea,
576
+ ::file-selector-button {
577
+ font: inherit;
578
+ font-feature-settings: inherit;
579
+ font-variation-settings: inherit;
580
+ letter-spacing: inherit;
581
+ color: inherit;
582
+ border-radius: 0;
583
+ background-color: transparent;
584
+ opacity: 1;
585
+ }
586
+ ::file-selector-button {
587
+ margin-inline-end: 4px;
588
+ }
589
+ ::placeholder {
590
+ opacity: 1;
591
+ }
592
+ @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
593
+ ::placeholder {
594
+ color: color-mix(in oklab, currentcolor 50%, transparent);
595
+ }
596
+ }
597
+ textarea {
598
+ resize: vertical;
599
+ }
600
+ ::-webkit-search-decoration {
601
+ -webkit-appearance: none;
602
+ }
603
+ :-moz-ui-invalid {
604
+ box-shadow: none;
605
+ }
606
+ button,
607
+ input:where([type='button'], [type='reset'], [type='submit']),
608
+ ::file-selector-button {
609
+ appearance: button;
610
+ }
611
+ ::-webkit-inner-spin-button,
612
+ ::-webkit-outer-spin-button {
613
+ height: auto;
614
+ }
615
+ [hidden]:where(:not([hidden='until-found'])) {
616
+ display: none !important;
617
+ }
618
+ </style>`
303
619
 
304
- /** Accent color (buttons, links). @default ['#171717', '#ededed'] */
305
- accent?: LightDark | undefined
306
- /** Page background. @default ['#ffffff', '#0a0a0a'] */
307
- background?: LightDark | undefined
308
- /** Border color. @default ['#e5e5e5', '#2e2e2e'] */
309
- border?: LightDark | undefined
310
- /** Primary text/content color. @default ['#0a0a0a', '#ededed'] */
311
- foreground?: LightDark | undefined
312
- /** Secondary/muted text. @default ['#666666', '#a1a1a1'] */
313
- muted?: LightDark | undefined
314
- /** Error/danger color. @default ['#e5484d', '#e5484d'] */
315
- negative?: LightDark | undefined
316
- /** Success color. @default ['#30a46c', '#30a46c'] */
317
- positive?: LightDark | undefined
318
- /** Input/card surface. @default ['#f5f5f5', '#1a1a1a'] */
319
- surface?: LightDark | undefined
620
+ function sanitize(str: string): string {
621
+ return str
622
+ .replace(/&/g, '&amp;')
623
+ .replace(/</g, '&lt;')
624
+ .replace(/>/g, '&gt;')
625
+ .replace(/"/g, '&quot;')
626
+ .replace(/'/g, '&#39;')
320
627
  }
321
628
 
322
- export type LightDark = string | readonly [light: string, dark: string]
323
-
324
- export const defaultTheme = {
325
- colorScheme: 'light dark',
326
- fontFamily: 'system-ui, -apple-system, sans-serif',
327
- fontSizeBase: '16px',
328
- radius: '6px',
329
- spacingUnit: '2px',
330
-
331
- accent: ['#171717', '#ededed'],
332
- background: ['#ffffff', '#0a0a0a'],
333
- border: ['#e5e5e5', '#2e2e2e'],
334
- foreground: ['#0a0a0a', '#ededed'],
335
- muted: ['#666666', '#a1a1a1'],
336
- negative: ['#e5484d', '#e5484d'],
337
- positive: ['#30a46c', '#30a46c'],
338
- surface: ['#f5f5f5', '#1a1a1a'],
339
- } as const satisfies Required<Omit<Theme, 'favicon' | 'fontUrl' | 'logo'>>
340
-
341
- export const colorTokens = [
342
- 'accent',
343
- 'negative',
344
- 'positive',
345
- 'background',
346
- 'foreground',
347
- 'muted',
348
- 'surface',
349
- 'border',
350
- ] as const satisfies readonly (keyof typeof defaultTheme)[]
351
-
352
- export function resolveColor(
353
- value: Theme[(typeof colorTokens)[number]] | undefined,
354
- fallback: readonly [string, string],
355
- ): readonly [light: string, dark: string] {
356
- if (!value) return fallback
357
- if (typeof value === 'string') return [value, value]
358
- return value
629
+ function sanitizeRecord<type extends Record<string, string>>(record: type): type {
630
+ return Object.fromEntries(
631
+ Object.entries(record).map(([key, value]) => [key, sanitize(value)]),
632
+ ) as type
359
633
  }
360
634
 
361
635
  export function mergeDefined<type>(defaults: type, value: DeepPartial<type> | undefined): type {
362
- if (value === undefined) return defaults
636
+ if (value === undefined || value === null) return defaults
363
637
  if (!isPlainObject(defaults) || !isPlainObject(value)) return (value ?? defaults) as type
364
638
 
365
639
  const result: Record<string, unknown> = { ...defaults }
366
640
 
367
641
  for (const [key, nextValue] of Object.entries(value)) {
368
- if (nextValue === undefined) continue
642
+ if (nextValue === undefined || nextValue === null || nextValue === '') continue
369
643
 
370
644
  const currentValue = result[key]
371
645
 
@@ -388,27 +662,3 @@ type DeepPartial<type> = {
388
662
  function isPlainObject(value: unknown): value is Record<string, unknown> {
389
663
  return typeof value === 'object' && value !== null && !Array.isArray(value)
390
664
  }
391
-
392
- // Slimmed down Tailwind preflight
393
- // https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/preflight.css
394
- const reset = html`
395
- *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0;
396
- padding: 0; border: 0 solid; border-color: ${vars.border}; } html, :host { line-height: 1.5;
397
- -webkit-text-size-adjust: 100%; tab-size: 4; -webkit-tap-highlight-color: transparent; } h1, h2,
398
- h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit;
399
- -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; }
400
- code, kbd, samp, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
401
- 'Liberation Mono', 'Courier New', monospace; font-size: 1em; } small { font-size: 80%; } ol, ul,
402
- menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block;
403
- vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select,
404
- optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit;
405
- font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0;
406
- background-color: transparent; opacity: 1; } ::file-selector-button { margin-inline-end: 4px; }
407
- ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or
408
- (contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%,
409
- transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance:
410
- none; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'],
411
- [type='reset'], [type='submit']), ::file-selector-button { appearance: button; }
412
- ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; }
413
- [hidden]:where(:not([hidden='until-found'])) { display: none !important; }
414
- `