mppx 0.5.4 → 0.5.6

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 (66) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Html.d.ts +10 -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 +46 -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/internal/html.gen.d.ts +1 -1
  30. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  31. package/dist/stripe/server/internal/html.gen.js +1 -1
  32. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  33. package/dist/tempo/Attribution.d.ts +24 -7
  34. package/dist/tempo/Attribution.d.ts.map +1 -1
  35. package/dist/tempo/Attribution.js +33 -7
  36. package/dist/tempo/Attribution.js.map +1 -1
  37. package/dist/tempo/client/Charge.js +1 -1
  38. package/dist/tempo/client/Charge.js.map +1 -1
  39. package/dist/tempo/server/Charge.d.ts.map +1 -1
  40. package/dist/tempo/server/Charge.js +36 -2
  41. package/dist/tempo/server/Charge.js.map +1 -1
  42. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  43. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  44. package/dist/tempo/server/internal/html.gen.js +1 -1
  45. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  46. package/package.json +6 -1
  47. package/src/Html.ts +57 -0
  48. package/src/server/Mppx.test.ts +203 -0
  49. package/src/server/Mppx.ts +118 -3
  50. package/src/server/Transport.test.ts +5 -2
  51. package/src/server/Transport.ts +21 -54
  52. package/src/server/internal/html/compose.main.gen.ts +2 -0
  53. package/src/server/internal/html/compose.main.ts +88 -0
  54. package/src/server/internal/html/config.ts +422 -177
  55. package/src/server/internal/html/constants.ts +28 -0
  56. package/src/server/internal/html/serviceWorker.client.ts +2 -2
  57. package/src/server/internal/html/tsconfig.compose.json +8 -0
  58. package/src/stripe/server/internal/html/main.ts +44 -53
  59. package/src/stripe/server/internal/html.gen.ts +1 -1
  60. package/src/tempo/Attribution.test.ts +129 -23
  61. package/src/tempo/Attribution.ts +39 -10
  62. package/src/tempo/client/Charge.ts +1 -1
  63. package/src/tempo/server/Charge.test.ts +205 -5
  64. package/src/tempo/server/Charge.ts +54 -3
  65. package/src/tempo/server/internal/html/main.ts +26 -28
  66. 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,236 @@ 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
96
80
  }
97
81
 
98
- export function style(theme: {
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
133
+ }
134
+
135
+ type LightDark = string | readonly [light: string, dark: string]
136
+
137
+ function resolveColor(
138
+ value: Theme[(typeof colorTokens)[number]] | undefined,
139
+ fallback: readonly [string, string],
140
+ ): readonly [light: string, dark: string] {
141
+ if (!value) return fallback
142
+ if (typeof value === 'string') return [value, value]
143
+ return value
144
+ }
145
+
146
+ const colorTokens = [
147
+ 'accent',
148
+ 'negative',
149
+ 'positive',
150
+ 'background',
151
+ 'foreground',
152
+ 'muted',
153
+ 'surface',
154
+ 'border',
155
+ ] as const satisfies readonly (keyof typeof defaultTheme)[]
156
+
157
+ export function resolveOptions(options: Options): {
158
+ theme: ResolvedTheme
159
+ text: ResolvedText
160
+ } {
161
+ const theme = mergeDefined(
162
+ {
163
+ favicon: undefined as Theme['favicon'],
164
+ fontUrl: undefined as Theme['fontUrl'],
165
+ logo: undefined as Theme['logo'],
166
+ ...defaultTheme,
167
+ },
168
+ (options.theme as never) ?? {},
169
+ )
170
+ const text = sanitizeRecord(mergeDefined(defaultText, (options.text as never) ?? {}))
171
+ return { theme, text }
172
+ }
173
+
174
+ type ResolvedTheme = {
175
+ [k in keyof Omit<Theme, 'favicon' | 'fontUrl' | 'logo'>]-?: NonNullable<Theme[k]>
176
+ } & Pick<Theme, 'favicon' | 'fontUrl' | 'logo'>
177
+ type ResolvedText = { [k in keyof Text]-?: NonNullable<Text[k]> }
178
+
179
+ const html = String.raw
180
+
181
+ export function render(options: {
182
+ entries: readonly {
183
+ challenge: Challenge.Challenge
184
+ content: string
185
+ }[]
186
+ dataMap: Record<string, Data>
187
+ formattedAmount: string
188
+ /** Whether to render panel wrappers around each entry. @default entries.length > 1 */
189
+ panels?: boolean | undefined
190
+ text: ResolvedText
191
+ theme: ResolvedTheme
192
+ }): string {
193
+ const { entries, dataMap, formattedAmount, text, theme } = options
194
+ const firstChallenge = entries[0]!.challenge
195
+ const hasTabs = entries.length > 1
196
+ const hasPanels = options.panels ?? hasTabs
197
+ const dataValues = Object.values(dataMap)
198
+
199
+ const tabListHtml = hasTabs
200
+ ? html`<nav class="${classNames.tabList}" role="tablist" aria-label="Payment methods">
201
+ ${dataValues
202
+ .map(
203
+ (data, i) =>
204
+ html`<button
205
+ class="${classNames.tab}"
206
+ role="tab"
207
+ id="mppx-tab-${i}"
208
+ aria-selected="${i === 0 ? 'true' : 'false'}"
209
+ aria-controls="mppx-panel-${i}"
210
+ ${i !== 0 ? 'tabindex="-1"' : ''}
211
+ data-amount="${sanitize(data.formattedAmount)}"
212
+ ${data.challenge.description
213
+ ? `data-description="${sanitize(data.challenge.description)}"`
214
+ : ''}
215
+ ${data.challenge.expires
216
+ ? `data-expires="${sanitize(data.challenge.expires)}"`
217
+ : ''}
218
+ ${data.challenge.expires ? `data-expires-label="${sanitize(text.expires)}"` : ''}
219
+ >
220
+ ${sanitize(data.label)}
221
+ </button>`,
222
+ )
223
+ .join('')}
224
+ </nav>`
225
+ : ''
226
+
227
+ const panelsHtml = hasPanels
228
+ ? entries
229
+ .map(
230
+ (_entry, i) =>
231
+ html`<div
232
+ ${hasTabs ? `role="tabpanel" aria-labelledby="mppx-tab-${i}"` : ''}
233
+ id="mppx-panel-${i}"
234
+ ${i !== 0 ? 'hidden' : ''}
235
+ >
236
+ <div id="${ids.root}-${i}" aria-label="Payment form"></div>
237
+ </div>`,
238
+ )
239
+ .join('')
240
+ : html`<div id="${ids.root}" aria-label="Payment form"></div>`
241
+
242
+ const contentScripts = hasTabs
243
+ ? entries
244
+ .map((entry) =>
245
+ entry.content.replace(
246
+ '<script>',
247
+ `<script ${attrs.challengeId}="${sanitize(entry.challenge.id)}">`,
248
+ ),
249
+ )
250
+ .join('\n')
251
+ : entries[0]!.content
252
+
253
+ return html`<!doctype html>
254
+ <html lang="en">
255
+ <head>
256
+ <meta charset="UTF-8" />
257
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
258
+ <meta name="robots" content="noindex" />
259
+ <meta name="color-scheme" content="${theme.colorScheme}" />
260
+ <title>${text.title}</title>
261
+ ${preflight} ${favicon(theme, firstChallenge.realm)} ${font(theme)} ${style(theme)}
262
+ ${hasTabs ? tabStyle() : ''}
263
+ </head>
264
+ <body>
265
+ <main>
266
+ <header class="${classNames.header}">
267
+ ${logo(theme)}
268
+ <span>${text.paymentRequired}</span>
269
+ </header>
270
+ <section class="${classNames.summary}" aria-label="Payment summary">
271
+ <h1 class="${classNames.summaryAmount}">${sanitize(formattedAmount)}</h1>
272
+ ${firstChallenge.description
273
+ ? `<p class="${classNames.summaryDescription}">${sanitize(firstChallenge.description)}</p>`
274
+ : ''}
275
+ ${firstChallenge.expires
276
+ ? `<p class="${classNames.summaryExpires}">${text.expires} <time datetime="${new Date(firstChallenge.expires).toISOString()}">${new Date(firstChallenge.expires).toLocaleString()}</time></p>`
277
+ : ''}
278
+ </section>
279
+ ${tabListHtml} ${panelsHtml}
280
+ <script
281
+ id="${ids.data}"
282
+ type="application/json"
283
+ ${entries.length > 1 ? ` ${attrs.remaining}="${entries.length}"` : ''}
284
+ >
285
+ ${Json.stringify(dataMap satisfies Record<string, Data>).replace(/</g, '\\u003c')}
286
+ </script>
287
+ ${contentScripts} ${hasTabs ? tabScript : ''}
288
+ </main>
289
+ </body>
290
+ </html>`
291
+ }
292
+
293
+ function style(theme: {
99
294
  [k in keyof Omit<Theme, 'favicon' | 'fontUrl' | 'logo'>]-?: NonNullable<Theme[k]>
100
295
  }) {
101
296
  const colors = Object.fromEntries(
@@ -116,7 +311,6 @@ export function style(theme: {
116
311
  : ''
117
312
  return html`
118
313
  <style>
119
- ${reset}
120
314
  :root {
121
315
  color-scheme: ${theme.colorScheme};
122
316
  ${vars.fontFamily.name}: ${theme.fontFamily};
@@ -167,12 +361,12 @@ export function style(theme: {
167
361
  .${classNames.logo} {
168
362
  max-height: 1.75rem;
169
363
  }
170
- .${classNames.logoColorScheme('dark')} {
364
+ .${logoColorScheme('dark')} {
171
365
  @media (prefers-color-scheme: light) {
172
366
  display: none;
173
367
  }
174
368
  }
175
- .${classNames.logoColorScheme('light')} {
369
+ .${logoColorScheme('light')} {
176
370
  @media (prefers-color-scheme: dark) {
177
371
  display: none;
178
372
  }
@@ -208,21 +402,7 @@ export function style(theme: {
208
402
  `
209
403
  }
210
404
 
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) {
405
+ function favicon(theme: Theme, realm: string) {
226
406
  if (typeof theme.favicon === 'string')
227
407
  return html`<link rel="icon" href="${sanitize(theme.favicon)}" />`
228
408
  if (typeof theme.favicon === 'object') {
@@ -249,7 +429,17 @@ export function favicon(theme: Theme, realm: string) {
249
429
  }
250
430
  }
251
431
 
252
- export function logo(value: Theme) {
432
+ function font(theme: Theme) {
433
+ if (!theme.fontUrl) return ''
434
+ return html`<link
435
+ rel="preconnect"
436
+ href="${sanitize(new URL(theme.fontUrl).origin)}"
437
+ crossorigin
438
+ />
439
+ <link rel="stylesheet" href="${sanitize(theme.fontUrl)}" />`
440
+ }
441
+
442
+ function logo(value: Theme) {
253
443
  if (typeof value.logo === 'undefined') return ''
254
444
  if (typeof value.logo === 'string')
255
445
  return html`<img alt="" class="${classNames.logo}" src="${sanitize(value.logo)}" />`
@@ -258,114 +448,193 @@ export function logo(value: Theme) {
258
448
  (entry) =>
259
449
  html`<img
260
450
  alt=""
261
- class="${classNames.logo} ${classNames.logoColorScheme(entry[0])}"
451
+ class="${classNames.logo} ${logoColorScheme(entry[0])}"
262
452
  src="${sanitize(entry[1])}"
263
453
  />`,
264
454
  )
265
455
  .join('\n')
266
456
  }
267
457
 
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
458
+ function tabStyle() {
459
+ return html`
460
+ <style>
461
+ .${classNames.tabList} {
462
+ display: flex;
463
+ gap: calc(${vars.spacingUnit} * 2);
464
+ border-bottom: 1px solid ${vars.border};
465
+ }
466
+ .${classNames.tab} {
467
+ background: none !important;
468
+ border: none !important;
469
+ border-bottom: 1px solid transparent !important;
470
+ border-radius: 0 !important;
471
+ color: ${vars.muted} !important;
472
+ cursor: pointer;
473
+ font-size: 0.875rem;
474
+ font-weight: 500;
475
+ margin-bottom: -1px !important;
476
+ padding: calc(${vars.spacingUnit} * 2) calc(${vars.spacingUnit} * 4) !important;
477
+ text-transform: capitalize;
478
+ width: auto !important;
479
+ }
480
+ .${classNames.tab}[aria-selected='true'] {
481
+ border-bottom-color: ${vars.foreground} !important;
482
+ color: ${vars.foreground} !important;
483
+ }
484
+ .${classNames.tab}:hover:not([aria-selected='true']) {
485
+ color: ${vars.foreground} !important;
486
+ }
487
+ .${classNames.tabPanel}[hidden] {
488
+ display: none;
489
+ }
490
+ </style>
491
+ `
277
492
  }
278
493
 
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
494
+ // Slimmed down Tailwind preflight
495
+ // https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/preflight.css
496
+ const preflight = html`<style>
497
+ *,
498
+ ::after,
499
+ ::before,
500
+ ::backdrop,
501
+ ::file-selector-button {
502
+ box-sizing: border-box;
503
+ margin: 0;
504
+ padding: 0;
505
+ border: 0 solid;
506
+ border-color: ${vars.border};
507
+ }
508
+ html,
509
+ :host {
510
+ line-height: 1.5;
511
+ -webkit-text-size-adjust: 100%;
512
+ tab-size: 4;
513
+ -webkit-tap-highlight-color: transparent;
514
+ }
515
+ h1,
516
+ h2,
517
+ h3,
518
+ h4,
519
+ h5,
520
+ h6 {
521
+ font-size: inherit;
522
+ font-weight: inherit;
523
+ }
524
+ a {
525
+ color: inherit;
526
+ -webkit-text-decoration: inherit;
527
+ text-decoration: inherit;
528
+ }
529
+ b,
530
+ strong {
531
+ font-weight: bolder;
532
+ }
533
+ code,
534
+ kbd,
535
+ samp,
536
+ pre {
537
+ font-family:
538
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
539
+ monospace;
540
+ font-size: 1em;
541
+ }
542
+ small {
543
+ font-size: 80%;
544
+ }
545
+ ol,
546
+ ul,
547
+ menu {
548
+ list-style: none;
549
+ }
550
+ img,
551
+ svg,
552
+ video,
553
+ canvas,
554
+ audio,
555
+ iframe,
556
+ embed,
557
+ object {
558
+ display: block;
559
+ vertical-align: middle;
560
+ }
561
+ img,
562
+ video {
563
+ max-width: 100%;
564
+ height: auto;
565
+ }
566
+ button,
567
+ input,
568
+ select,
569
+ optgroup,
570
+ textarea,
571
+ ::file-selector-button {
572
+ font: inherit;
573
+ font-feature-settings: inherit;
574
+ font-variation-settings: inherit;
575
+ letter-spacing: inherit;
576
+ color: inherit;
577
+ border-radius: 0;
578
+ background-color: transparent;
579
+ opacity: 1;
580
+ }
581
+ ::file-selector-button {
582
+ margin-inline-end: 4px;
583
+ }
584
+ ::placeholder {
585
+ opacity: 1;
586
+ }
587
+ @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
588
+ ::placeholder {
589
+ color: color-mix(in oklab, currentcolor 50%, transparent);
590
+ }
591
+ }
592
+ textarea {
593
+ resize: vertical;
594
+ }
595
+ ::-webkit-search-decoration {
596
+ -webkit-appearance: none;
597
+ }
598
+ :-moz-ui-invalid {
599
+ box-shadow: none;
600
+ }
601
+ button,
602
+ input:where([type='button'], [type='reset'], [type='submit']),
603
+ ::file-selector-button {
604
+ appearance: button;
605
+ }
606
+ ::-webkit-inner-spin-button,
607
+ ::-webkit-outer-spin-button {
608
+ height: auto;
609
+ }
610
+ [hidden]:where(:not([hidden='until-found'])) {
611
+ display: none !important;
612
+ }
613
+ </style>`
303
614
 
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
615
+ function sanitize(str: string): string {
616
+ return str
617
+ .replace(/&/g, '&amp;')
618
+ .replace(/</g, '&lt;')
619
+ .replace(/>/g, '&gt;')
620
+ .replace(/"/g, '&quot;')
621
+ .replace(/'/g, '&#39;')
320
622
  }
321
623
 
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
624
+ function sanitizeRecord<type extends Record<string, string>>(record: type): type {
625
+ return Object.fromEntries(
626
+ Object.entries(record).map(([key, value]) => [key, sanitize(value)]),
627
+ ) as type
359
628
  }
360
629
 
361
630
  export function mergeDefined<type>(defaults: type, value: DeepPartial<type> | undefined): type {
362
- if (value === undefined) return defaults
631
+ if (value === undefined || value === null) return defaults
363
632
  if (!isPlainObject(defaults) || !isPlainObject(value)) return (value ?? defaults) as type
364
633
 
365
634
  const result: Record<string, unknown> = { ...defaults }
366
635
 
367
636
  for (const [key, nextValue] of Object.entries(value)) {
368
- if (nextValue === undefined) continue
637
+ if (nextValue === undefined || nextValue === null || nextValue === '') continue
369
638
 
370
639
  const currentValue = result[key]
371
640
 
@@ -388,27 +657,3 @@ type DeepPartial<type> = {
388
657
  function isPlainObject(value: unknown): value is Record<string, unknown> {
389
658
  return typeof value === 'object' && value !== null && !Array.isArray(value)
390
659
  }
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
- `