better-ship 0.34.0 → 0.35.1
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.
- package/dist/browser-support/floor.d.ts +43 -0
- package/dist/browser-support/floor.d.ts.map +1 -0
- package/dist/browser-support/floor.js +3 -0
- package/dist/browser-support/oxlint.d.ts +6 -0
- package/dist/browser-support/oxlint.d.ts.map +1 -0
- package/dist/browser-support/oxlint.js +175 -0
- package/dist/browser-support/oxlint.js.map +1 -0
- package/dist/browser-support/polyfills.d.ts +10 -0
- package/dist/browser-support/polyfills.d.ts.map +1 -0
- package/dist/browser-support/polyfills.js +3 -0
- package/dist/browser-support/update-screen.d.ts +51 -0
- package/dist/browser-support/update-screen.d.ts.map +1 -0
- package/dist/browser-support/update-screen.js +107 -0
- package/dist/browser-support/update-screen.js.map +1 -0
- package/dist/floor-Cuw8EIhg.js +146 -0
- package/dist/floor-Cuw8EIhg.js.map +1 -0
- package/dist/polyfills-DEQFkqRI.js +28 -0
- package/dist/polyfills-DEQFkqRI.js.map +1 -0
- package/package.json +25 -5
- package/src/infrastructure/browser-support/.browserslistrc +5 -0
- package/src/infrastructure/browser-support/browser-floor.error.ts +31 -0
- package/src/infrastructure/browser-support/floor.ts +145 -0
- package/src/infrastructure/browser-support/oxlint/index.ts +13 -0
- package/src/infrastructure/browser-support/oxlint/no-unsupported-api.ts +219 -0
- package/src/infrastructure/browser-support/polyfills.ts +25 -0
- package/src/infrastructure/browser-support/update-screen.ts +142 -0
|
@@ -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.
|
|
4
|
+
"version": "0.35.1",
|
|
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
|
-
"@sentry/cloudflare": "^11.
|
|
85
|
-
"@sentry/tanstackstart-react": "^11.
|
|
90
|
+
"@sentry/cloudflare": "^11.6.0",
|
|
91
|
+
"@sentry/tanstackstart-react": "^11.6.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
|
-
"@sentry/cloudflare": "^11.
|
|
115
|
-
"@sentry/tanstackstart-react": "^11.
|
|
124
|
+
"@sentry/cloudflare": "^11.6.0",
|
|
125
|
+
"@sentry/tanstackstart-react": "^11.6.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,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
|
+
}
|