better-ship 0.34.0 → 0.35.0

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.
@@ -0,0 +1,28 @@
1
+ //#region src/infrastructure/browser-support/polyfills.ts
2
+ /** Platform APIs `installPolyfills` provides. The lint rule allows these names. */
3
+ const POLYFILLED_APIS = ["URL.parse"];
4
+ /**
5
+ * Install `URL.parse` when the host has none.
6
+ * A native `URL.parse` on `URL` stays in place.
7
+ */
8
+ function installPolyfills() {
9
+ if (Object.getOwnPropertyDescriptor(URL, "parse") !== void 0) return;
10
+ Object.defineProperty(URL, "parse", {
11
+ configurable: true,
12
+ writable: true,
13
+ value: parseUrl
14
+ });
15
+ }
16
+ function parseUrl(url, base) {
17
+ try {
18
+ if (base === void 0) return new URL(url);
19
+ return new URL(url, base);
20
+ } catch (cause) {
21
+ if (cause instanceof TypeError) return null;
22
+ throw cause;
23
+ }
24
+ }
25
+
26
+ //#endregion
27
+ export { installPolyfills as n, POLYFILLED_APIS as t };
28
+ //# sourceMappingURL=polyfills-DEQFkqRI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"polyfills-DEQFkqRI.js","names":[],"sources":["../src/infrastructure/browser-support/polyfills.ts"],"sourcesContent":["/** Platform APIs `installPolyfills` provides. The lint rule allows these names. */\nexport const POLYFILLED_APIS = ['URL.parse'] as const\n\n/**\n * Install `URL.parse` when the host has none.\n * A native `URL.parse` on `URL` stays in place.\n */\nexport function installPolyfills(): void {\n if (Object.getOwnPropertyDescriptor(URL, 'parse') !== undefined) return\n Object.defineProperty(URL, 'parse', {\n configurable: true,\n writable: true,\n value: parseUrl,\n })\n}\n\nfunction parseUrl(url: string | URL, base?: string | URL): URL | null {\n try {\n if (base === undefined) return new URL(url)\n return new URL(url, base)\n } catch (cause) {\n if (cause instanceof TypeError) return null\n throw cause\n }\n}\n"],"mappings":";;AACA,MAAa,kBAAkB,CAAC,WAAW;;;;;AAM3C,SAAgB,mBAAyB;CACvC,IAAI,OAAO,yBAAyB,KAAK,OAAO,MAAM,QAAW;CACjE,OAAO,eAAe,KAAK,SAAS;EAClC,cAAc;EACd,UAAU;EACV,OAAO;CACT,CAAC;AACH;AAEA,SAAS,SAAS,KAAmB,MAAiC;CACpE,IAAI;EACF,IAAI,SAAS,QAAW,OAAO,IAAI,IAAI,GAAG;EAC1C,OAAO,IAAI,IAAI,KAAK,IAAI;CAC1B,SAAS,OAAO;EACd,IAAI,iBAAiB,WAAW,OAAO;EACvC,MAAM;CACR;AACF"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "better-ship",
4
- "version": "0.34.0",
4
+ "version": "0.35.0",
5
5
  "description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -27,6 +27,10 @@
27
27
  "./better-auth/stripe": "./dist/better-auth/stripe.js",
28
28
  "./billing/client": "./dist/billing/client.js",
29
29
  "./bin/notify-ci": "./dist/bin/notify-ci.js",
30
+ "./browser-support/floor": "./dist/browser-support/floor.js",
31
+ "./browser-support/oxlint": "./dist/browser-support/oxlint.js",
32
+ "./browser-support/polyfills": "./dist/browser-support/polyfills.js",
33
+ "./browser-support/update-screen": "./dist/browser-support/update-screen.js",
30
34
  "./cloudflare": "./dist/cloudflare.js",
31
35
  "./cloudflare/auth-secondary-storage": "./dist/cloudflare/auth-secondary-storage.js",
32
36
  "./cloudflare/durable-object-client": "./dist/cloudflare/durable-object-client.js",
@@ -80,9 +84,12 @@
80
84
  "@better-auth/stripe": "^1.7.7",
81
85
  "@cloudflare/vitest-plugin": "^1.3.4",
82
86
  "@cloudflare/workers-types": "5.20260930.2",
87
+ "@mdn/browser-compat-data": "^8.1.4",
88
+ "@oxlint/plugins": "^1.86.0",
83
89
  "@posthog/react": "^1.11.3",
84
90
  "@sentry/cloudflare": "^11.2.0",
85
91
  "@sentry/tanstackstart-react": "^11.2.0",
92
+ "@tailwindcss/node": "^4.3.3",
86
93
  "@tanstack/react-router": "^1.170.41",
87
94
  "@tanstack/react-start": "^1.168.60",
88
95
  "@tanstack/start-client-core": "^1.170.34",
@@ -93,6 +100,7 @@
93
100
  "@vitest/browser-playwright": "^4.1.11",
94
101
  "auth": "^1.7.7",
95
102
  "better-auth": "^1.7.7",
103
+ "browserslist": "^4.29.3",
96
104
  "drizzle-kit": "^0.31.11",
97
105
  "drizzle-orm": "^0.45.3",
98
106
  "pg": "^8.23.1",
@@ -110,12 +118,15 @@
110
118
  },
111
119
  "peerDependencies": {
112
120
  "@better-auth/stripe": "^1.7.7",
121
+ "@mdn/browser-compat-data": "^8.1.4",
122
+ "@oxlint/plugins": "^1.86.0",
113
123
  "@posthog/react": "^1.11.3",
114
124
  "@sentry/cloudflare": "^11.2.0",
115
125
  "@sentry/tanstackstart-react": "^11.2.0",
116
126
  "@tanstack/react-router": "^1.170.41",
117
127
  "@tanstack/react-start": "^1.168.60",
118
128
  "better-auth": "^1.7.7",
129
+ "browserslist": "^4.29.3",
119
130
  "drizzle-orm": "^0.45.3",
120
131
  "pg": "^8.23.1",
121
132
  "posthog-js": "^1.435.6",
@@ -166,6 +177,15 @@
166
177
  },
167
178
  "pg": {
168
179
  "optional": true
180
+ },
181
+ "@mdn/browser-compat-data": {
182
+ "optional": true
183
+ },
184
+ "@oxlint/plugins": {
185
+ "optional": true
186
+ },
187
+ "browserslist": {
188
+ "optional": true
169
189
  }
170
190
  },
171
191
  "engines": {
@@ -0,0 +1,5 @@
1
+ chrome >= 111
2
+ edge >= 111
3
+ firefox >= 128
4
+ safari >= 16.4
5
+ ios >= 16.4
@@ -0,0 +1,31 @@
1
+ import { AppError } from '@/core'
2
+
3
+ /** The browserslist file is missing, empty, or not a query browserslist can resolve. */
4
+ export class BrowserFloorError extends AppError {
5
+ readonly _tag = 'BrowserFloorError'
6
+ readonly classification = 'terminal'
7
+
8
+ /**
9
+ * `path` is the file or directory passed to `browserFloor`.
10
+ * `reason` is `missing` when no config is found, `empty` when the query matches no browsers,
11
+ * and `invalid` when browserslist rejects the query. `cause` is that rejection.
12
+ */
13
+ constructor(
14
+ readonly path: string,
15
+ readonly reason: 'missing' | 'empty' | 'invalid',
16
+ cause?: unknown,
17
+ ) {
18
+ super(floorMessage(reason, path), { cause })
19
+ }
20
+ }
21
+
22
+ function floorMessage(reason: 'missing' | 'empty' | 'invalid', path: string): string {
23
+ switch (reason) {
24
+ case 'missing':
25
+ return `No browserslist config at ${path}`
26
+ case 'empty':
27
+ return `The browserslist config at ${path} matches no browsers`
28
+ case 'invalid':
29
+ return `The browserslist config at ${path} is invalid`
30
+ }
31
+ }
@@ -0,0 +1,145 @@
1
+ import browserslist from 'browserslist'
2
+
3
+ import { BrowserFloorError } from './browser-floor.error.ts'
4
+
5
+ export { BrowserFloorError }
6
+
7
+ /** Oldest and newest version of one browser that the floor still includes. */
8
+ export type BrowserFloorRelease = {
9
+ readonly browser: string
10
+ readonly minimum: string
11
+ readonly maximum: string
12
+ }
13
+
14
+ /** Title and body for the update screen, taken from the floor versions. */
15
+ export type UpdateScreenCopy = {
16
+ readonly title: string
17
+ readonly body: string
18
+ }
19
+
20
+ /**
21
+ * Resolve the minimum and maximum version of each browser in the browserslist config.
22
+ * The path is a config file or any file inside the project that owns `.browserslistrc`.
23
+ * Throws `BrowserFloorError` when the config is missing, empty, or invalid.
24
+ */
25
+ export function browserFloor(path: string): readonly BrowserFloorRelease[] {
26
+ const queries = loadQueries(path)
27
+ if (queries === undefined) throw new BrowserFloorError(path, 'missing')
28
+ if (queries.length === 0) throw new BrowserFloorError(path, 'empty')
29
+ let resolved: readonly string[]
30
+ try {
31
+ resolved = browserslist(queries, { path })
32
+ } catch (cause) {
33
+ throw new BrowserFloorError(path, 'invalid', cause)
34
+ }
35
+ const releases = floorReleases(resolved)
36
+ if (releases.length === 0) throw new BrowserFloorError(path, 'empty')
37
+ return releases
38
+ }
39
+
40
+ /**
41
+ * Vite `environments.client.build.target` values for the browserslist config at `path`.
42
+ * Throws `BrowserFloorError` when the config is missing, empty, or invalid.
43
+ */
44
+ export function buildTargets(path: string): readonly string[] {
45
+ return browserFloor(path).map((release) => `${viteBrowser(release.browser)}${release.minimum}`)
46
+ }
47
+
48
+ /** Sentence that names the floor versions. Pass the result of `browserFloor`. */
49
+ export function updateScreenCopy(releases: readonly BrowserFloorRelease[]): UpdateScreenCopy {
50
+ const versions = releases
51
+ .map((release) => `${browserLabel(release.browser)} ${release.minimum}`)
52
+ .join(', ')
53
+ return {
54
+ title: 'Update your browser',
55
+ body: `This application needs ${versions}.`,
56
+ }
57
+ }
58
+
59
+ function loadQueries(path: string): readonly string[] | undefined {
60
+ try {
61
+ return browserslist.loadConfig({ path })
62
+ } catch (cause) {
63
+ throw new BrowserFloorError(path, 'invalid', cause)
64
+ }
65
+ }
66
+
67
+ function floorReleases(resolved: readonly string[]): BrowserFloorRelease[] {
68
+ const ranges = new Map<string, { minimum: string; maximum: string }>()
69
+ for (const entry of resolved) {
70
+ const release = splitRelease(entry)
71
+ if (release === undefined) continue
72
+ const current = ranges.get(release.browser)
73
+ if (current === undefined) {
74
+ ranges.set(release.browser, { minimum: release.version, maximum: release.version })
75
+ continue
76
+ }
77
+ if (compareVersionText(release.version, current.minimum) < 0) current.minimum = release.version
78
+ if (compareVersionText(release.version, current.maximum) > 0) current.maximum = release.version
79
+ }
80
+ return [...ranges.entries()]
81
+ .map(([browser, range]) => ({ browser, minimum: range.minimum, maximum: range.maximum }))
82
+ .sort((left, right) => compareText(left.browser, right.browser))
83
+ }
84
+
85
+ function splitRelease(
86
+ entry: string,
87
+ ): { readonly browser: string; readonly version: string } | undefined {
88
+ const space = entry.indexOf(' ')
89
+ if (space === -1) return undefined
90
+ const browser = entry.slice(0, space)
91
+ const version = entry.slice(space + 1)
92
+ if (versionParts(version) === undefined) return undefined
93
+ return { browser, version }
94
+ }
95
+
96
+ function compareVersionText(left: string, right: string): number {
97
+ const leftParts = versionParts(left)
98
+ const rightParts = versionParts(right)
99
+ if (leftParts === undefined || rightParts === undefined) return 0
100
+ return compareVersions(leftParts, rightParts)
101
+ }
102
+
103
+ function versionParts(version: string): readonly number[] | undefined {
104
+ const normalized =
105
+ version.startsWith('≤') || version.startsWith('<=') ? version.replace(/^≤|^<=/, '') : version
106
+ if (!/^\d+(?:\.\d+)*$/.test(normalized)) return undefined
107
+ return normalized.split('.').map((part) => Number(part))
108
+ }
109
+
110
+ function compareVersions(left: readonly number[], right: readonly number[]): number {
111
+ const length = Math.max(left.length, right.length)
112
+ for (let index = 0; index < length; index += 1) {
113
+ const difference = (left[index] ?? 0) - (right[index] ?? 0)
114
+ if (difference !== 0) return difference
115
+ }
116
+ return 0
117
+ }
118
+
119
+ function compareText(left: string, right: string): number {
120
+ if (left < right) return -1
121
+ if (left > right) return 1
122
+ return 0
123
+ }
124
+
125
+ function viteBrowser(browser: string): string {
126
+ if (browser === 'ios_saf') return 'ios'
127
+ return browser
128
+ }
129
+
130
+ function browserLabel(browser: string): string {
131
+ switch (browser) {
132
+ case 'chrome':
133
+ return 'Chrome'
134
+ case 'edge':
135
+ return 'Edge'
136
+ case 'firefox':
137
+ return 'Firefox'
138
+ case 'safari':
139
+ return 'Safari'
140
+ case 'ios_saf':
141
+ return 'iOS'
142
+ default:
143
+ return browser
144
+ }
145
+ }
@@ -0,0 +1,13 @@
1
+ import { eslintCompatPlugin } from '@oxlint/plugins'
2
+
3
+ import { noUnsupportedApiRule } from './no-unsupported-api.ts'
4
+
5
+ /** Oxlint plugin. Enable `browser-support/no-unsupported-api` in `jsPlugins`. */
6
+ const browserSupportPlugin = eslintCompatPlugin({
7
+ meta: { name: 'browser-support' },
8
+ rules: {
9
+ 'no-unsupported-api': noUnsupportedApiRule,
10
+ },
11
+ })
12
+
13
+ export default browserSupportPlugin
@@ -0,0 +1,219 @@
1
+ import type {
2
+ BrowserName,
3
+ CompatStatement,
4
+ Identifier,
5
+ SimpleSupportStatement,
6
+ SupportStatement,
7
+ } from '@mdn/browser-compat-data/forLegacyNode'
8
+ import bcd from '@mdn/browser-compat-data/forLegacyNode'
9
+ import { defineRule } from '@oxlint/plugins'
10
+ import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins'
11
+
12
+ import type { BrowserFloorRelease } from '../floor.ts'
13
+ import { BrowserFloorError, browserFloor } from '../floor.ts'
14
+ import { POLYFILLED_APIS } from '../polyfills.ts'
15
+
16
+ const polyfilledApis = new Set<string>(POLYFILLED_APIS)
17
+
18
+ /**
19
+ * Reject a global static call that a browser in the floor does not implement.
20
+ * `POLYFILLED_APIS` and a `// FALLBACK:` comment on the statement are allowed.
21
+ * The floor is the nearest `.browserslistrc` from the linted file.
22
+ */
23
+ export const noUnsupportedApiRule = defineRule({
24
+ meta: {
25
+ type: 'problem',
26
+ docs: {
27
+ description:
28
+ 'Disallow a platform API that a browser in the browserslist floor does not implement.',
29
+ },
30
+ messages: {
31
+ unsupported: '{{api}} is missing in {{browsers}}.',
32
+ floor: 'The browserslist floor for {{path}} is {{reason}}.',
33
+ },
34
+ },
35
+ createOnce(context) {
36
+ const floors = new Map<string, readonly BrowserFloorRelease[] | BrowserFloorError>()
37
+ const loadFloor = (path: string): readonly BrowserFloorRelease[] | BrowserFloorError => {
38
+ const cached = floors.get(path)
39
+ if (cached !== undefined) return cached
40
+ try {
41
+ const releases = browserFloor(path)
42
+ floors.set(path, releases)
43
+ return releases
44
+ } catch (cause) {
45
+ if (!(cause instanceof BrowserFloorError)) throw cause
46
+ floors.set(path, cause)
47
+ return cause
48
+ }
49
+ }
50
+
51
+ return {
52
+ Program(node) {
53
+ const floor = loadFloor(context.filename)
54
+ if (!(floor instanceof BrowserFloorError)) return
55
+ context.report({
56
+ node,
57
+ messageId: 'floor',
58
+ data: { path: context.filename, reason: floor.reason },
59
+ })
60
+ },
61
+ MemberExpression(node) {
62
+ const api = staticApi(node)
63
+ if (api === undefined) return
64
+ if (node.object.type !== 'Identifier') return
65
+ if (!isPlatformReference(context.sourceCode, node.object)) return
66
+ if (markedFallback(context.sourceCode, node)) return
67
+ if (polyfilledApis.has(api)) return
68
+ const floor = loadFloor(context.filename)
69
+ if (floor instanceof BrowserFloorError) return
70
+ const compat = compatFor(node.object.name, memberName(node))
71
+ if (compat === undefined) return
72
+ const browsers = unsupportedBrowsers(compat, floor)
73
+ if (browsers.length === 0) return
74
+ context.report({
75
+ node,
76
+ messageId: 'unsupported',
77
+ data: { api, browsers: browsers.join(', ') },
78
+ })
79
+ },
80
+ }
81
+ },
82
+ })
83
+
84
+ function staticApi(node: ESTree.MemberExpression): string | undefined {
85
+ if (node.computed || node.object.type !== 'Identifier' || node.property.type !== 'Identifier') {
86
+ return undefined
87
+ }
88
+ return `${node.object.name}.${node.property.name}`
89
+ }
90
+
91
+ function memberName(node: ESTree.MemberExpression): string {
92
+ if (node.property.type !== 'Identifier') return ''
93
+ return node.property.name
94
+ }
95
+
96
+ function isPlatformReference(
97
+ sourceCode: SourceCode,
98
+ identifier: ESTree.IdentifierReference,
99
+ ): boolean {
100
+ const variable = resolveVariable(sourceCode, identifier)
101
+ if (variable === null || variable.defs.length === 0) return true
102
+ return variable.defs.every((definition) => definition.type === 'ImplicitGlobalVariable')
103
+ }
104
+
105
+ function resolveVariable(
106
+ sourceCode: SourceCode,
107
+ identifier: ESTree.IdentifierReference,
108
+ ): Variable | null {
109
+ let scope: Scope | null = sourceCode.getScope(identifier)
110
+ while (scope !== null) {
111
+ const variable = scope.set.get(identifier.name)
112
+ if (variable !== undefined) return variable
113
+ scope = scope.upper
114
+ }
115
+ return null
116
+ }
117
+
118
+ function markedFallback(sourceCode: SourceCode, node: ESTree.Node): boolean {
119
+ let current: ESTree.Node | null = node
120
+ while (current !== null && current.type !== 'Program') {
121
+ const comments = [
122
+ ...sourceCode.getCommentsBefore(current),
123
+ ...sourceCode.getCommentsAfter(current),
124
+ ]
125
+ if (comments.some((comment) => comment.value.includes('FALLBACK:'))) return true
126
+ current = current.parent
127
+ }
128
+ return false
129
+ }
130
+
131
+ function compatFor(objectName: string, name: string): CompatStatement | undefined {
132
+ const builtins = bcd.javascript.builtins
133
+ const fromJavascript = builtins === undefined ? undefined : lookup(builtins, objectName, name)
134
+ return fromJavascript ?? lookup(bcd.api, objectName, name)
135
+ }
136
+
137
+ // BCD stores `URL.parse` as `parse_static`. The plain key is the instance or the builtin.
138
+ function lookup(
139
+ namespace: Identifier,
140
+ objectName: string,
141
+ name: string,
142
+ ): CompatStatement | undefined {
143
+ const objectData = namespace[objectName]
144
+ if (objectData === undefined) return undefined
145
+ return objectData[`${name}_static`]?.__compat ?? objectData[name]?.__compat
146
+ }
147
+
148
+ function unsupportedBrowsers(
149
+ compat: CompatStatement,
150
+ floor: readonly BrowserFloorRelease[],
151
+ ): readonly string[] {
152
+ const missing: string[] = []
153
+ for (const release of floor) {
154
+ const browser = compatBrowser(release.browser)
155
+ if (browser === undefined || !rangeSupports(compat.support[browser], release)) {
156
+ missing.push(`${release.browser} ${release.minimum}`)
157
+ }
158
+ }
159
+ return missing
160
+ }
161
+
162
+ function compatBrowser(browser: string): BrowserName | undefined {
163
+ switch (browser) {
164
+ case 'chrome':
165
+ case 'edge':
166
+ case 'firefox':
167
+ case 'safari':
168
+ return browser
169
+ case 'ios_saf':
170
+ return 'safari_ios'
171
+ default:
172
+ return undefined
173
+ }
174
+ }
175
+
176
+ function rangeSupports(
177
+ support: SupportStatement | undefined,
178
+ release: BrowserFloorRelease,
179
+ ): boolean {
180
+ if (support === undefined) return false
181
+ const statements = Array.isArray(support) ? support : [support]
182
+ return statements.some((statement) => statementSupports(statement, release))
183
+ }
184
+
185
+ // An alternative_name or a prefix is a different API. A flag is off by default.
186
+ function statementSupports(
187
+ statement: SimpleSupportStatement,
188
+ release: BrowserFloorRelease,
189
+ ): boolean {
190
+ if (statement.alternative_name !== undefined) return false
191
+ if (statement.prefix !== undefined) return false
192
+ if (statement.flags !== undefined) return false
193
+ if (statement.version_added === false) return false
194
+ const added = versionParts(statement.version_added)
195
+ const minimum = versionParts(release.minimum)
196
+ if (added === undefined || minimum === undefined) return false
197
+ if (compareVersions(minimum, added) < 0) return false
198
+ if (statement.version_removed === undefined) return true
199
+ const removed = versionParts(statement.version_removed)
200
+ const maximum = versionParts(release.maximum)
201
+ if (removed === undefined || maximum === undefined) return true
202
+ return compareVersions(maximum, removed) < 0
203
+ }
204
+
205
+ function versionParts(version: string): readonly number[] | undefined {
206
+ const normalized =
207
+ version.startsWith('≤') || version.startsWith('<=') ? version.replace(/^≤|^<=/, '') : version
208
+ if (!/^\d+(?:\.\d+)*$/.test(normalized)) return undefined
209
+ return normalized.split('.').map((part) => Number(part))
210
+ }
211
+
212
+ function compareVersions(left: readonly number[], right: readonly number[]): number {
213
+ const length = Math.max(left.length, right.length)
214
+ for (let index = 0; index < length; index += 1) {
215
+ const difference = (left[index] ?? 0) - (right[index] ?? 0)
216
+ if (difference !== 0) return difference
217
+ }
218
+ return 0
219
+ }
@@ -0,0 +1,25 @@
1
+ /** Platform APIs `installPolyfills` provides. The lint rule allows these names. */
2
+ export const POLYFILLED_APIS = ['URL.parse'] as const
3
+
4
+ /**
5
+ * Install `URL.parse` when the host has none.
6
+ * A native `URL.parse` on `URL` stays in place.
7
+ */
8
+ export function installPolyfills(): void {
9
+ if (Object.getOwnPropertyDescriptor(URL, 'parse') !== undefined) return
10
+ Object.defineProperty(URL, 'parse', {
11
+ configurable: true,
12
+ writable: true,
13
+ value: parseUrl,
14
+ })
15
+ }
16
+
17
+ function parseUrl(url: string | URL, base?: string | URL): URL | null {
18
+ try {
19
+ if (base === undefined) return new URL(url)
20
+ return new URL(url, base)
21
+ } catch (cause) {
22
+ if (cause instanceof TypeError) return null
23
+ throw cause
24
+ }
25
+ }
@@ -0,0 +1,142 @@
1
+ /** Set on `documentElement` when the update screen is showing. Hydration checks this name. */
2
+ export const unsupportedBrowserAttribute = 'data-unsupported-browser'
3
+
4
+ const updateTitleAttribute = 'data-update-title' as const
5
+ const updateBodyAttribute = 'data-update-body' as const
6
+ const fallbackFont = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif'
7
+
8
+ /** Copy and public URLs for the update screen tags in the document head. */
9
+ export type UpdateScreenHead = {
10
+ readonly title: string
11
+ readonly body: string
12
+ /** Public URL of `updateScreenStyle`. */
13
+ readonly styleHref: string
14
+ /** Public URL of `updateScreenScript`. */
15
+ readonly scriptSrc: string
16
+ }
17
+
18
+ /** Stylesheet link a root route places in `head().links`. */
19
+ export type UpdateScreenLink = {
20
+ readonly rel: 'stylesheet'
21
+ readonly href: string
22
+ }
23
+
24
+ /** Deferred script a root route places in `head().scripts`. */
25
+ export type UpdateScreenScript = {
26
+ readonly src: string
27
+ readonly defer: true
28
+ readonly 'data-update-title': string
29
+ readonly 'data-update-body': string
30
+ }
31
+
32
+ /**
33
+ * `links` and `scripts` a root route spreads into `head()`.
34
+ * The arrays are mutable so they match the `head()` return type.
35
+ */
36
+ export type UpdateScreenHeadEntries = {
37
+ links: UpdateScreenLink[]
38
+ scripts: UpdateScreenScript[]
39
+ }
40
+
41
+ /**
42
+ * Stylesheet and deferred script entries for a root route `head()`.
43
+ * Spread `links` and `scripts` into `head()`. The router escapes the attribute values.
44
+ * `defer` keeps parsing unblocked. The script is classic ES5, so a browser below the floor can show the screen.
45
+ */
46
+ export function updateScreenHeadScript(head: UpdateScreenHead): UpdateScreenHeadEntries {
47
+ return {
48
+ links: [{ rel: 'stylesheet', href: head.styleHref }],
49
+ scripts: [
50
+ {
51
+ src: head.scriptSrc,
52
+ defer: true,
53
+ [updateTitleAttribute]: head.title,
54
+ [updateBodyAttribute]: head.body,
55
+ },
56
+ ],
57
+ }
58
+ }
59
+
60
+ /** True when the update screen script marked this element. Skip hydration when it is true. */
61
+ export function isUnsupportedBrowser(root: {
62
+ readonly hasAttribute: (name: string) => boolean
63
+ }): boolean {
64
+ return root.hasAttribute(unsupportedBrowserAttribute)
65
+ }
66
+
67
+ /**
68
+ * Classic ES5 script. Serve it at `scriptSrc`.
69
+ * It shows the screen when `CSS.registerProperty` or `color-mix()` is missing, and does nothing at the floor.
70
+ */
71
+ export const updateScreenScript = `(function () {
72
+ var cssApi = typeof CSS === 'undefined' ? null : CSS;
73
+ var hasProperty = cssApi !== null && typeof cssApi.registerProperty === 'function';
74
+ var hasMix = cssApi !== null && typeof cssApi.supports === 'function' && cssApi.supports('color', 'color-mix(in srgb, red, blue)');
75
+ if (hasProperty && hasMix) return;
76
+ document.documentElement.setAttribute('${unsupportedBrowserAttribute}', '');
77
+ var scripts = document.getElementsByTagName('script');
78
+ var title = 'Update your browser';
79
+ var body = '';
80
+ for (var index = 0; index < scripts.length; index = index + 1) {
81
+ var marker = scripts[index].getAttribute('${updateTitleAttribute}');
82
+ if (marker === null) continue;
83
+ title = marker;
84
+ var text = scripts[index].getAttribute('${updateBodyAttribute}');
85
+ body = text === null ? '' : text;
86
+ break;
87
+ }
88
+ var screen = document.createElement('div');
89
+ screen.id = 'unsupported-browser';
90
+ var heading = document.createElement('h1');
91
+ heading.className = 'unsupported-browser-title';
92
+ heading.textContent = title;
93
+ var paragraph = document.createElement('p');
94
+ paragraph.className = 'unsupported-browser-body';
95
+ paragraph.textContent = body;
96
+ screen.appendChild(heading);
97
+ screen.appendChild(paragraph);
98
+ document.body.appendChild(screen);
99
+ })();
100
+ `
101
+
102
+ // `!important` keeps application styles from painting over the update screen.
103
+ /** Classic CSS for the update screen. Serve it at `styleHref`. Tokens fall back to neutral grey and a system sans-serif. */
104
+ export const updateScreenStyle = `html[${unsupportedBrowserAttribute}],
105
+ html[${unsupportedBrowserAttribute}] body {
106
+ margin: 0;
107
+ min-height: 100%;
108
+ background: var(--band, #f5f5f5);
109
+ }
110
+ html[${unsupportedBrowserAttribute}] body > :not(#unsupported-browser) {
111
+ display: none !important;
112
+ }
113
+ #unsupported-browser {
114
+ box-sizing: border-box;
115
+ min-height: 100vh;
116
+ margin: 0;
117
+ padding: 48px 24px;
118
+ display: flex;
119
+ flex-direction: column;
120
+ justify-content: center;
121
+ align-items: center;
122
+ text-align: center;
123
+ background: var(--band, #f5f5f5);
124
+ color: var(--foreground, #171717);
125
+ font-family: var(--font-text, ${fallbackFont});
126
+ }
127
+ .unsupported-browser-title {
128
+ margin: 0 0 12px;
129
+ max-width: 28rem;
130
+ font-family: var(--font-brand, ${fallbackFont});
131
+ font-size: 2rem;
132
+ font-weight: 500;
133
+ line-height: 1.2;
134
+ }
135
+ .unsupported-browser-body {
136
+ margin: 0;
137
+ max-width: 32rem;
138
+ color: var(--muted-foreground, #525252);
139
+ font-size: 1.0625rem;
140
+ line-height: 1.5;
141
+ }
142
+ `