mockaton 13.3.5 → 13.4.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.
- package/README.md +28 -16
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/client/ApiCommander.js +1 -1
- package/src/client/ApiConstants.js +2 -2
- package/src/client/IndexHtml.js +18 -18
- package/src/client/app-header.js +3 -3
- package/src/client/app-payload-viewer.js +4 -8
- package/src/client/app-store.js +3 -34
- package/src/client/app.js +3 -2
- package/src/client/dir/dittoSplitPaths.js +25 -0
- package/src/client/dir/dittoSplitPaths.test.js +28 -0
- package/src/client/{dir-tree.js → dir/groupByFolder.js} +2 -33
- package/src/client/{dir-tree.test.js → dir/groupByFolder.test.js} +2 -26
- package/src/client/graphics.js +2 -2
- package/src/client/utils/LocalStorage.js +69 -0
- package/src/client/utils/css.js +16 -0
- package/src/client/{dom-utils-test.js → utils/css.test.js} +1 -1
- package/src/client/utils/dom.js +68 -0
- package/src/client/utils/watcherDev.js +46 -0
- package/src/server/Api.js +16 -3
- package/src/server/MockDispatcher.js +11 -8
- package/src/server/Mockaton.js +16 -8
- package/src/server/Mockaton.test.js +14 -9
- package/src/server/ProxyRelay.js +9 -3
- package/src/server/{utils/UrlParsers.js → UrlParsers.js} +10 -4
- package/src/server/{utils/UrlParsers.test.js → UrlParsers.test.js} +14 -14
- package/src/server/config.js +2 -0
- package/src/server/utils/HttpServerResponse.js +18 -39
- package/src/server/{WatcherDevClient.js → utils/WatcherDevClient.js} +3 -17
- package/src/server/utils/fs.js +1 -1
- package/src/server/utils/logger.js +4 -4
- package/src/server/utils/mime.js +11 -11
- package/src/server/utils/mime.test.js +15 -11
- package/www/src/assets/openapi.json +147 -147
- package/src/client/dom-utils.js +0 -154
- package/src/client/watcherDev.js +0 -39
package/README.md
CHANGED
|
@@ -10,6 +10,26 @@ for testing difficult to reproduce backend states.
|
|
|
10
10
|
|
|
11
11
|
## [Documentation ↗](https://mockaton.com) | [Changelog ↗](https://mockaton.com/changelog)
|
|
12
12
|
|
|
13
|
+
## TL;DR
|
|
14
|
+
```shell
|
|
15
|
+
npx mockaton my-mocks-dir
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It’s like `servedir`, but supports dynamic segments in filenames. For example:
|
|
19
|
+
|
|
20
|
+
**Route**: [/api/company/123](#) <br/>
|
|
21
|
+
**File**: my-mocks-dir/api/company/[id].GET.200.json
|
|
22
|
+
|
|
23
|
+
Statics assets don’t need that extension:
|
|
24
|
+
|
|
25
|
+
**Route**: [/media/avatar.png](#) <br/>
|
|
26
|
+
**File**: my-mocks-dir/media/avatar.png
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## Dashboard
|
|
30
|
+
Besides the dashboard, there’s a programmatic [Control API](https://mockaton.com/api).
|
|
31
|
+
Also, there’s a [Browser Extension](https://mockaton.com/scraping) for scraping responses from your backend.
|
|
32
|
+
|
|
13
33
|
<picture>
|
|
14
34
|
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.light.gold.png">
|
|
15
35
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ericfortis/mockaton/refs/heads/main/pixaton-tests/tests/macos/pic-for-readme.vp762x762.dark.gold.png">
|
|
@@ -36,12 +56,8 @@ curl localhost:2020/api/user
|
|
|
36
56
|
```
|
|
37
57
|
|
|
38
58
|
|
|
39
|
-
##
|
|
40
|
-
|
|
41
|
-
mocks. Instead, a given directory is scanned for filenames
|
|
42
|
-
following a convention similar to the URLs.
|
|
43
|
-
|
|
44
|
-
For example, for [/api/company/123](#), the file could be:
|
|
59
|
+
## Examples
|
|
60
|
+
[/api/company/123](#)
|
|
45
61
|
|
|
46
62
|
<code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.json</code>
|
|
47
63
|
```json
|
|
@@ -50,6 +66,8 @@ For example, for [/api/company/123](#), the file could be:
|
|
|
50
66
|
}
|
|
51
67
|
```
|
|
52
68
|
|
|
69
|
+
<br/>
|
|
70
|
+
|
|
53
71
|
Or, you can write it in TypeScript (it will be sent as JSON).
|
|
54
72
|
|
|
55
73
|
<code>my_mocks_dir/<b>api/company/[id]</b>.GET.200.ts</code>
|
|
@@ -59,15 +77,17 @@ export default {
|
|
|
59
77
|
}
|
|
60
78
|
```
|
|
61
79
|
|
|
80
|
+
<br/>
|
|
81
|
+
|
|
62
82
|
Similarly, you can handle logic with [Functional Mocks](https://mockaton.com/functional-mocks):
|
|
63
83
|
|
|
64
84
|
<code>my_mocks_dir/<b>api/company/[companyId]/user/[userId]</b>.GET.200.ts</code>
|
|
65
85
|
```ts
|
|
66
86
|
import { IncomingMessage, OutgoingMessage } from 'node:http'
|
|
67
|
-
import {
|
|
87
|
+
import { parseSegments } from 'mockaton'
|
|
68
88
|
|
|
69
89
|
export default async function (req: IncomingMessage, response: OutgoingMessage) {
|
|
70
|
-
const { companyId, userId } =
|
|
90
|
+
const { companyId, userId } = parseSegments(req.url, import.meta.filename)
|
|
71
91
|
const foo = await getFoo()
|
|
72
92
|
return JSON.stringify({
|
|
73
93
|
foo,
|
|
@@ -77,11 +97,3 @@ export default async function (req: IncomingMessage, response: OutgoingMessage)
|
|
|
77
97
|
})
|
|
78
98
|
}
|
|
79
99
|
```
|
|
80
|
-
|
|
81
|
-
## Browser Extension
|
|
82
|
-
[Browser Extension](https://mockaton.com/scraping) for scraping responses from your backend.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
## API
|
|
86
|
-
Programmatic [Control API](https://mockaton.com/api).
|
|
87
|
-
|
package/index.d.ts
CHANGED
|
@@ -62,7 +62,7 @@ export const echoFilePlugin: Plugin
|
|
|
62
62
|
export function jwtCookie(cookieName: string, payload: any, path?: string): string
|
|
63
63
|
|
|
64
64
|
export function parseJSON(request: IncomingMessage): Promise<any>
|
|
65
|
-
export function
|
|
65
|
+
export function parseSegments(reqUrl: string, filename: string): Record<string, string>
|
|
66
66
|
export function parseQueryParams(reqUrl: string): URLSearchParams
|
|
67
67
|
|
|
68
68
|
export type JsonPromise<T> = Promise<Response & { json(): Promise<T> }>
|
package/index.js
CHANGED
|
@@ -5,6 +5,6 @@ export { Mockaton } from './src/server/Mockaton.js'
|
|
|
5
5
|
export { jwtCookie } from './src/server/utils/jwt.js'
|
|
6
6
|
export { jsToJsonPlugin, echoFilePlugin } from './src/server/MockDispatcherPlugins.js'
|
|
7
7
|
export { parseJSON, BodyReaderError } from './src/server/utils/HttpIncomingMessage.js'
|
|
8
|
-
export {
|
|
8
|
+
export { parseSegments, parseQueryParams, parseSplats } from './src/server/UrlParsers.js'
|
|
9
9
|
|
|
10
10
|
export const defineConfig = opts => opts
|
package/package.json
CHANGED
|
@@ -25,9 +25,9 @@ export const API = {
|
|
|
25
25
|
deleteMock: MOUNT + '/delete-mock',
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export const HEADER_502 = 'Mockaton502'
|
|
29
|
-
|
|
30
28
|
export const DEFAULT_MOCK_COMMENT = '(default)'
|
|
31
29
|
|
|
32
30
|
export const EXT_UNKNOWN_MIME = 'unknown'
|
|
33
31
|
export const EXT_EMPTY = 'empty'
|
|
32
|
+
|
|
33
|
+
export const FILENAME_HEADER = 'Mockaton-File'
|
package/src/client/IndexHtml.js
CHANGED
|
@@ -7,24 +7,24 @@ export const CSP = [
|
|
|
7
7
|
|
|
8
8
|
// language=html
|
|
9
9
|
export const IndexHtml = (hotReloadEnabled, version) => `
|
|
10
|
-
<!DOCTYPE html>
|
|
11
|
-
<html lang="en-US">
|
|
12
|
-
<head>
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
<!DOCTYPE html>
|
|
11
|
+
<html lang="en-US">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="UTF-8">
|
|
14
|
+
<base href="${API.dashboard}/">
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
<script type="module" src="app.js"></script>
|
|
17
|
+
<link rel="preload" href="${API.state}" as="fetch" crossorigin>
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
</head>
|
|
24
|
-
<body>
|
|
25
|
-
${hotReloadEnabled
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
</body>
|
|
29
|
-
</html>
|
|
19
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m235 33.7v202c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-151c-0.115-4.49-6.72-5.88-8.46-0.87l-48.3 155c-2.22 7.01-7.72 10.1-16 9.9-3.63-0.191-7.01-1.14-9.66-2.89-2.89-1.72-4.83-4.34-5.57-7.72-11.1-37-22.6-74.3-34.1-111-4.34-14-8.95-31.4-14-48.3-1.82-4.83-8.16-5.32-8.46 1.16v156c0 9.19-5.81 14-17.4 14-11.6 0-17.4-4.83-17.4-14v-207c0-5.74 2.62-13.2 9.39-16.3 7.5-3.14 15-4.05 21.8-3.8 3.14 0 6.03 0.686 8.95 1.46 3.14 0.797 6.03 1.98 8.7 3.63 2.65 1.38 5.32 3.14 7.5 5.57 2.22 2.22 3.87 4.83 5.07 7.72l45.8 157c4.63-15.9 32.4-117 33.3-121 4.12-13.8 7.72-26.5 10.9-38.7 1.16-2.65 2.89-5.32 5.07-7.5 2.15-2.15 4.58-4.12 7.5-5.32 2.65-1.57 5.57-2.89 8.46-3.63 3.14-0.797 9.44-0.988 12.1-0.988 11.6 1.07 29.4 9.14 29.4 27z' fill='%23808080'/%3E%3C/svg%3E">
|
|
20
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
21
|
+
<meta name="description" content="HTTP Mock Server">
|
|
22
|
+
<title>Mockaton v${version}</title>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
${hotReloadEnabled
|
|
26
|
+
? `<script type="module" src="utils/watcherDev.js?url=${API.watchHotReload}"></script>`
|
|
27
|
+
: ''}
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
30
30
|
`
|
package/src/client/app-header.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { createElement as r, t
|
|
1
|
+
import { createElement as r, t } from './utils/dom.js'
|
|
2
2
|
import { Logo, HelpIcon } from './graphics.js'
|
|
3
3
|
import { store } from './app-store.js'
|
|
4
4
|
|
|
5
5
|
import CSS from './app.css' with { type: 'css' }
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import { extractClassNames } from './utils/css.js'
|
|
8
7
|
Object.assign(CSS, extractClassNames(CSS))
|
|
9
8
|
|
|
9
|
+
|
|
10
10
|
export function Header() {
|
|
11
11
|
return (
|
|
12
12
|
r('header', null,
|
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import { createElement as r, t
|
|
2
|
-
import { HEADER_502 } from './ApiConstants.js'
|
|
1
|
+
import { createElement as r, t } from './utils/dom.js'
|
|
3
2
|
import { parseFilename } from './Filename.js'
|
|
4
3
|
import { store } from './app-store.js'
|
|
5
4
|
|
|
6
5
|
import CSS from './app.css' with { type: 'css' }
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { extractClassNames } from './utils/css.js'
|
|
9
7
|
Object.assign(CSS, extractClassNames(CSS))
|
|
10
8
|
|
|
9
|
+
|
|
11
10
|
const titleRef = {}
|
|
12
11
|
const codeRef = {}
|
|
13
12
|
|
|
@@ -42,12 +41,9 @@ function PayloadViewerTitle(file, statusText) {
|
|
|
42
41
|
|
|
43
42
|
function PayloadViewerTitleWhenProxied(response) {
|
|
44
43
|
const mime = response.headers.get('content-type') || ''
|
|
45
|
-
const badGateway = response.headers.get(HEADER_502)
|
|
46
44
|
return (
|
|
47
45
|
r('span', null,
|
|
48
|
-
|
|
49
|
-
? r('span', null, t`⛔ Fallback Backend Error` + ' ')
|
|
50
|
-
: r('span', null, t`Got` + ' '),
|
|
46
|
+
r('span', null, t`Got` + ' '),
|
|
51
47
|
r('abbr', { title: response.statusText }, response.status),
|
|
52
48
|
' ' + mime))
|
|
53
49
|
}
|
package/src/client/app-store.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Commander } from './ApiCommander.js'
|
|
2
|
-
import {
|
|
2
|
+
import { groupByFolder } from './dir/groupByFolder.js'
|
|
3
|
+
import { dittoSplitPaths } from './dir/dittoSplitPaths.js'
|
|
3
4
|
import { parseFilename, extractComments } from './Filename.js'
|
|
4
|
-
import {
|
|
5
|
+
import { LocalStorageSet, QueryParamBool } from './utils/LocalStorage.js'
|
|
5
6
|
import { EXT_UNKNOWN_MIME, EXT_EMPTY } from './ApiConstants.js'
|
|
6
7
|
|
|
7
8
|
|
|
@@ -205,38 +206,6 @@ export const store = {
|
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
208
|
|
|
208
|
-
// When false, the URL will be updated with param=false
|
|
209
|
-
function initPreference(param) {
|
|
210
|
-
const qs = new URLSearchParams(globalThis.location?.search)
|
|
211
|
-
if (!qs.has(param)) {
|
|
212
|
-
const group = globalThis.localStorage?.getItem(param) !== '0'
|
|
213
|
-
if (!group) {
|
|
214
|
-
const url = new URL(globalThis.location?.href)
|
|
215
|
-
url.searchParams.set(param, '0')
|
|
216
|
-
history.replaceState(null, '', url)
|
|
217
|
-
}
|
|
218
|
-
return group
|
|
219
|
-
}
|
|
220
|
-
return qs.get(param) !== '0'
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// When false, the URL and localStorage will have param='0'
|
|
224
|
-
function togglePreference(param, nextVal) {
|
|
225
|
-
if (nextVal)
|
|
226
|
-
globalThis.localStorage?.removeItem(param)
|
|
227
|
-
else
|
|
228
|
-
globalThis.localStorage?.setItem(param, nextVal)
|
|
229
|
-
|
|
230
|
-
const url = new URL(location.href)
|
|
231
|
-
if (nextVal)
|
|
232
|
-
url.searchParams.delete(param)
|
|
233
|
-
else
|
|
234
|
-
url.searchParams.set(param, '0')
|
|
235
|
-
history.replaceState(null, '', url)
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
209
|
export class BrokerRowModel {
|
|
241
210
|
opts = /** @type {[key:string, label:string, selected:boolean][]} */ []
|
|
242
211
|
isNew = false
|
package/src/client/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createElement as r, t, restoreFocus, Fragment
|
|
1
|
+
import { createElement as r, t, restoreFocus, Fragment } from './utils/dom.js'
|
|
2
2
|
|
|
3
3
|
import { store } from './app-store.js'
|
|
4
4
|
import { API } from './ApiConstants.js'
|
|
@@ -7,10 +7,11 @@ import { PayloadViewer, previewMock } from './app-payload-viewer.js'
|
|
|
7
7
|
import { TimerIcon, CloudIcon, ChevronDownIcon } from './graphics.js'
|
|
8
8
|
|
|
9
9
|
import CSS from './app.css' with { type: 'css' }
|
|
10
|
+
import { extractClassNames, classNames } from './utils/css.js'
|
|
10
11
|
document.adoptedStyleSheets.push(CSS)
|
|
11
|
-
|
|
12
12
|
Object.assign(CSS, extractClassNames(CSS))
|
|
13
13
|
|
|
14
|
+
|
|
14
15
|
store.onError = onError
|
|
15
16
|
store.render = render
|
|
16
17
|
store.renderRow = renderRow
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Think of this as a way of printing a directory tree in which
|
|
3
|
+
* the repeated folder paths are kept but styled differently.
|
|
4
|
+
* @param {string[]} paths - sorted
|
|
5
|
+
*/
|
|
6
|
+
export function dittoSplitPaths(paths) {
|
|
7
|
+
const pParts = paths.map(p => p.split('/').filter(Boolean))
|
|
8
|
+
return paths.map((p, i) => {
|
|
9
|
+
if (i === 0)
|
|
10
|
+
return ['', p]
|
|
11
|
+
|
|
12
|
+
const prev = pParts[i - 1]
|
|
13
|
+
const curr = pParts[i]
|
|
14
|
+
const min = Math.min(curr.length, prev.length)
|
|
15
|
+
let j = 0
|
|
16
|
+
while (j < min && curr[j] === prev[j])
|
|
17
|
+
j++
|
|
18
|
+
|
|
19
|
+
if (!j) // no common dirs
|
|
20
|
+
return ['', p]
|
|
21
|
+
|
|
22
|
+
const ditto = '/' + curr.slice(0, j).join('/') + '/'
|
|
23
|
+
return [ditto, p.slice(ditto.length)]
|
|
24
|
+
})
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import { deepEqual } from 'node:assert/strict'
|
|
3
|
+
import { dittoSplitPaths } from './dittoSplitPaths.js'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
test('dittoSplitPaths', () => {
|
|
7
|
+
const input = [
|
|
8
|
+
'/api/user',
|
|
9
|
+
'/api/user/avatar',
|
|
10
|
+
'/api/user/friends',
|
|
11
|
+
'/api/vid',
|
|
12
|
+
'/api/video/id',
|
|
13
|
+
'/api/video/stats',
|
|
14
|
+
'/v2/foo',
|
|
15
|
+
'/v2/foo/bar'
|
|
16
|
+
]
|
|
17
|
+
deepEqual(dittoSplitPaths(input), [
|
|
18
|
+
['', '/api/user'],
|
|
19
|
+
['/api/user/', 'avatar'],
|
|
20
|
+
['/api/user/', 'friends'],
|
|
21
|
+
['/api/', 'vid'],
|
|
22
|
+
['/api/', 'video/id'],
|
|
23
|
+
['/api/video/', 'stats'],
|
|
24
|
+
['', '/v2/foo'],
|
|
25
|
+
['/v2/foo/', 'bar']
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
|
|
@@ -1,31 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Think of this as a way of printing a directory tree in which
|
|
3
|
-
* the repeated folder paths are kept but styled differently.
|
|
4
|
-
* @param {string[]} paths - sorted
|
|
5
|
-
*/
|
|
6
|
-
export function dittoSplitPaths(paths) {
|
|
7
|
-
const pParts = paths.map(p => p.split('/').filter(Boolean))
|
|
8
|
-
return paths.map((p, i) => {
|
|
9
|
-
if (i === 0)
|
|
10
|
-
return ['', p]
|
|
11
|
-
|
|
12
|
-
const prev = pParts[i - 1]
|
|
13
|
-
const curr = pParts[i]
|
|
14
|
-
const min = Math.min(curr.length, prev.length)
|
|
15
|
-
let j = 0
|
|
16
|
-
while (j < min && curr[j] === prev[j])
|
|
17
|
-
j++
|
|
18
|
-
|
|
19
|
-
if (!j) // no common dirs
|
|
20
|
-
return ['', p]
|
|
21
|
-
|
|
22
|
-
const ditto = '/' + curr.slice(0, j).join('/') + '/'
|
|
23
|
-
return [ditto, p.slice(ditto.length)]
|
|
24
|
-
})
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
1
|
/**
|
|
30
2
|
* @param {Partial<BrokerRowModel>[]} brokers
|
|
31
3
|
* @returns {Partial<BrokerRowModel>[]}
|
|
@@ -49,11 +21,8 @@ function trie(brokers) {
|
|
|
49
21
|
}
|
|
50
22
|
|
|
51
23
|
class TrieNode {
|
|
52
|
-
#children
|
|
53
|
-
|
|
54
|
-
this.brokers = []
|
|
55
|
-
this.#children = new Map()
|
|
56
|
-
}
|
|
24
|
+
#children = new Map()
|
|
25
|
+
brokers = []
|
|
57
26
|
addChild(k, v) { this.#children.set(k, v) }
|
|
58
27
|
getChild(k) { return this.#children.get(k) }
|
|
59
28
|
getChildren() { return this.#children.values() }
|
|
@@ -1,33 +1,9 @@
|
|
|
1
1
|
import { test } from 'node:test'
|
|
2
2
|
import { deepEqual } from 'node:assert/strict'
|
|
3
|
-
import { groupByFolder
|
|
3
|
+
import { groupByFolder } from './groupByFolder.js'
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
test('
|
|
7
|
-
const input = [
|
|
8
|
-
'/api/user',
|
|
9
|
-
'/api/user/avatar',
|
|
10
|
-
'/api/user/friends',
|
|
11
|
-
'/api/vid',
|
|
12
|
-
'/api/video/id',
|
|
13
|
-
'/api/video/stats',
|
|
14
|
-
'/v2/foo',
|
|
15
|
-
'/v2/foo/bar'
|
|
16
|
-
]
|
|
17
|
-
deepEqual(dittoSplitPaths(input), [
|
|
18
|
-
['', '/api/user'],
|
|
19
|
-
['/api/user/', 'avatar'],
|
|
20
|
-
['/api/user/', 'friends'],
|
|
21
|
-
['/api/', 'vid'],
|
|
22
|
-
['/api/', 'video/id'],
|
|
23
|
-
['/api/video/', 'stats'],
|
|
24
|
-
['', '/v2/foo'],
|
|
25
|
-
['/v2/foo/', 'bar']
|
|
26
|
-
])
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
test('dirStructure', () => {
|
|
6
|
+
test('groupByFolder', () => {
|
|
31
7
|
const input = [
|
|
32
8
|
{ children: [], method: 'GET', urlMask: '/api/user' },
|
|
33
9
|
{ children: [], method: 'GET', urlMask: '/api/user/avatar' },
|
package/src/client/graphics.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { createSvgElement as s } from './dom
|
|
1
|
+
import { createSvgElement as s } from './utils/dom.js'
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
export const Logo = () =>
|
|
5
5
|
s('svg', { viewBox: '0 0 556 100' },
|
|
6
6
|
s('path', { d: 'm13.75 1.8789c-5.9487 0.19352-10.865 4.5652-11.082 11.686v81.445c-1e-7 2.216 1.784 4 4 4h4.793c2.216 0 4-1.784 4-4v-64.982c0.02794-3.4488 3.0988-3.5551 4.2031-1.1562l16.615 59.059c1.4393 5.3711 5.1083 7.9633 8.7656 7.9473 3.6573 0.01603 7.3263-2.5762 8.7656-7.9473l16.615-59.059c1.1043-2.3989 4.1752-2.2925 4.2031 1.1562v64.982c0 2.216 1.784 4 4 4h4.793c2.216 0 4-1.784 4-4v-81.445c-0.17732-7.0807-5.1334-11.492-11.082-11.686-5.9487-0.19352-12.652 3.8309-15.609 13.619l-15.686 57.334-15.686-57.334c-2.9569-9.7882-9.6607-13.813-15.609-13.619zm239.19 0.074219c-2.216 0-4 1.784-4 4v89.057c0 2.216 1.784 4 4 4h4.793c2.216 0 3.9868-1.784 4-4l0.10644-17.94c0.0734-0.07237 12.175-13.75 12.175-13.75 5.6772 11.091 11.404 22.158 17.113 33.232 1.0168 1.9689 3.4217 2.7356 5.3906 1.7188l4.2578-2.1992c1.9689-1.0168 2.7356-3.4217 1.7188-5.3906-6.4691-12.585-12.958-25.16-19.442-37.738l17.223-19.771c1.4555-1.671 1.2803-4.189-0.39062-5.6445l-3.6133-3.1465c-0.73105-0.63679-1.6224-0.96212-2.5176-0.98633-1.151-0.03113-2.3063 0.43508-3.125 1.375l-28.896 33.174v-51.99c0-2.216-1.784-4-4-4zm-58.255 23.316c-10.699 0-19.312 8.6137-19.312 19.312v34.535c0 10.699 8.6137 19.312 19.312 19.312h19.717c10.699 0 19.311-8.6137 19.311-19.312l-0.125-7.8457c0-2.216-1.784-4-4-4h-4.6524c-2.216 0-4 1.784-4 4l3e-3 6.7888c3e-3 3.8063-1.5601 9.3694-8.4716 9.3694h-15.846c-6.9115 0-8.4766-5.5631-8.4766-12.475v-26.209c0-6.9115 1.5651-12.477 8.4766-12.477h15.846c6.6937 0 8.3697 5.2207 8.4687 11.828v2.2207c0 2.216 1.784 4 4 4h4.6524c2.216 0 4-1.784 4-4l0.125-5.7363c0-10.699-8.6117-19.312-19.311-19.312zm-72.182 0c-10.699 0-19.312 8.6137-19.312 19.312v34.535c0 10.699 8.6137 19.312 19.312 19.312h19.717c10.699 0 19.311-8.6137 19.311-19.312v-34.535c0-10.699-8.6117-19.312-19.311-19.312zm1.9356 11h15.846c6.9115 0 8.4746 5.5651 8.4746 12.477v26.209c0 6.9115-1.5631 12.475-8.4746 12.475h-15.846c-6.9115 0-8.4766-5.5631-8.4766-12.475v-26.209c0-6.9115 1.5651-12.477 8.4766-12.477z' }),
|
|
7
|
-
s('path', {
|
|
7
|
+
s('path', { fill: 'currentColor', d: 'm331.9 25.27c-10.699 0-19.312 8.6137-19.312 19.312v4.3682c0 2.216 1.784 4 4 4h4.7715c2.216 0 4-1.784 4-4v-0.20414c0-6.9115 1.5651-12.477 8.4766-12.477h15.846c6.9115 0 8.4746 5.5651 8.4746 12.477v7.0148h-28.059c-10.699 0-19.312 8.6117-19.312 19.311v4.0477c0 10.699 8.6137 19.313 19.312 19.312h17.812c2.216-1e-6 4-1.784 4-4v-4.7715c0-2.216-1.784-4-4-4h-13.648c-6.9115-2e-5 -12.477-1.5651-12.477-8.5649 0-6.9998 5.5651-8.5629 12.477-8.5629h23.895v25.897c0 2.216 1.784 4 4 4h4.7715c2.216-1e-6 4-1.784 4-4v-49.848c0-10.699-8.6117-19.312-19.311-19.312z' }),
|
|
8
8
|
s('path', { d: 'm392.75 1.373c-2.216 0-4 1.784-4 4v18.043h-5.3086c-2.216 0-4 1.784-4 4v4.793c0 2.216 1.784 4 4 4h5.3086v51.398c0 6.1465 3.7064 10.823 9.232 10.823h16.531c2.216 0 4-1.784 4-4v-4.793c0-2.216-1.784-4-4-4h-12.97v-49.428h9.8711c2.216 0 4-1.784 4-4v-4.793c0-2.216-1.784-4-4-4h-9.8711v-18.043c0-2.216-1.784-4-4-4zm122.96 23.896c-10.699 0-19.312 8.6137-19.312 19.312v49.812c0 2.216 1.784 4 4 4h4.7715c2.216 0 4-1.784 4-4v-45.648c0-6.9115 1.5651-12.477 8.4766-12.477h15.846c6.9115 0 8.4746 5.5651 8.4746 12.477v45.684c0 2.216 1.784 4 4 4h4.7715c2.216-1e-6 4-1.784 4-4v-49.848c0-10.699-8.6117-19.312-19.311-19.312zm-69.999 0c-10.699 0-19.312 8.6137-19.312 19.312v34.535c0 10.699 8.6137 19.312 19.312 19.312h19.717c10.699 0 19.311-8.6137 19.311-19.312v-34.535c0-10.699-8.6117-19.312-19.311-19.312zm1.9356 11h15.846c6.9115 0 8.4746 5.5651 8.4746 12.477v26.209c0 6.9115-1.5631 12.475-8.4746 12.475h-15.846c-6.9115 0-8.4766-5.5631-8.4766-12.475v-26.209c0-6.9115 1.5651-12.477 8.4766-12.477z' }))
|
|
9
9
|
|
|
10
10
|
export const TimerIcon = () =>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export class QueryParamBool {
|
|
2
|
+
constructor(param) {
|
|
3
|
+
this.param = param
|
|
4
|
+
this.value = this.#init()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
#init() {
|
|
8
|
+
const qs = new URLSearchParams(globalThis.location?.search)
|
|
9
|
+
if (qs.has(this.param))
|
|
10
|
+
return qs.get(this.param) !== '0'
|
|
11
|
+
const stored = globalThis.localStorage?.getItem(this.param) !== '0'
|
|
12
|
+
if (!stored)
|
|
13
|
+
this.#applyToUrl(false)
|
|
14
|
+
return stored
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
toggle() {
|
|
18
|
+
this.value = !this.value
|
|
19
|
+
if (this.value)
|
|
20
|
+
globalThis.localStorage?.removeItem(this.param)
|
|
21
|
+
else
|
|
22
|
+
globalThis.localStorage?.setItem(this.param, '0')
|
|
23
|
+
this.#applyToUrl(this.value)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#applyToUrl(nextVal) {
|
|
27
|
+
const url = new URL(globalThis.location?.href)
|
|
28
|
+
if (nextVal)
|
|
29
|
+
url.searchParams.delete(this.param)
|
|
30
|
+
else
|
|
31
|
+
url.searchParams.set(this.param, '0')
|
|
32
|
+
history.replaceState(null, '', url)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
export class LocalStorageSet {
|
|
38
|
+
constructor(key) {
|
|
39
|
+
this.key = key
|
|
40
|
+
this.value = this.#parse()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
add(item) {
|
|
44
|
+
this.value.add(item)
|
|
45
|
+
this.#persist()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
delete(item) {
|
|
49
|
+
this.value.delete(item)
|
|
50
|
+
this.#persist()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
has(item) {
|
|
54
|
+
return this.value.has(item)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#parse() {
|
|
58
|
+
try {
|
|
59
|
+
return new Set(JSON.parse(globalThis.localStorage?.getItem(this.key) || '[]'))
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return new Set()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#persist() {
|
|
67
|
+
globalThis.localStorage?.setItem(this.key, JSON.stringify([...this.value]))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function classNames(...args) {
|
|
2
|
+
return args.filter(Boolean).join(' ')
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export function extractClassNames({ cssRules }) {
|
|
7
|
+
// Class names must begin with _ or a letter, then it can have numbers and hyphens
|
|
8
|
+
// TODO think about tag.className selectors
|
|
9
|
+
const reClassName = /(?:^|[\s,{>])&?\s*\.([a-zA-Z_][\w-]*)/g
|
|
10
|
+
const cNames = {}
|
|
11
|
+
let match
|
|
12
|
+
for (const rule of cssRules)
|
|
13
|
+
while (match = reClassName.exec(rule.cssText))
|
|
14
|
+
cNames[match[1]] = match[1]
|
|
15
|
+
return cNames
|
|
16
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test'
|
|
2
2
|
import { deepEqual, equal } from 'node:assert/strict'
|
|
3
|
-
import {
|
|
3
|
+
import { extractClassNames, classNames } from './css.js'
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
test('classNames', () => equal(classNames('a', false && 'b'), 'a'))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export function t(translation) {
|
|
2
|
+
return translation[0]
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export function createElement(tag, props, ...children) {
|
|
7
|
+
const elem = document.createElement(tag)
|
|
8
|
+
if (props)
|
|
9
|
+
for (const [k, v] of Object.entries(props))
|
|
10
|
+
if (v === undefined) continue
|
|
11
|
+
else if (k === 'ref') v.elem = elem
|
|
12
|
+
else if (k === 'style') Object.assign(elem.style, v)
|
|
13
|
+
else if (k.startsWith('on')) elem.addEventListener(k.slice(2).toLowerCase(), ...[v].flat())
|
|
14
|
+
else if (k in elem) elem[k] = v
|
|
15
|
+
else elem.setAttribute(k, v)
|
|
16
|
+
elem.append(...children.flat().filter(Boolean))
|
|
17
|
+
return elem
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
export function createSvgElement(tag, props, ...children) {
|
|
22
|
+
const elem = document.createElementNS('http://www.w3.org/2000/svg', tag)
|
|
23
|
+
for (const [k, v] of Object.entries(props))
|
|
24
|
+
elem.setAttribute(k, v)
|
|
25
|
+
elem.append(...children.flat().filter(Boolean))
|
|
26
|
+
return elem
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
export function Fragment(...args) {
|
|
31
|
+
const frag = new DocumentFragment()
|
|
32
|
+
for (const arg of args)
|
|
33
|
+
if (Array.isArray(arg))
|
|
34
|
+
frag.append(...arg)
|
|
35
|
+
else
|
|
36
|
+
frag.appendChild(arg)
|
|
37
|
+
return frag
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
export function restoreFocus(cb) {
|
|
42
|
+
const focusQuery = selectorFor(document.activeElement)
|
|
43
|
+
cb()
|
|
44
|
+
if (focusQuery)
|
|
45
|
+
document.querySelector(focusQuery)?.focus()
|
|
46
|
+
}
|
|
47
|
+
function selectorFor(elem) {
|
|
48
|
+
if (!(elem instanceof Element))
|
|
49
|
+
return
|
|
50
|
+
const path = []
|
|
51
|
+
while (elem) {
|
|
52
|
+
let qualifier = ''
|
|
53
|
+
if (elem.hasAttribute('key'))
|
|
54
|
+
qualifier = `[key="${elem.getAttribute('key')}"]`
|
|
55
|
+
else {
|
|
56
|
+
let i = 0
|
|
57
|
+
let sib = elem
|
|
58
|
+
while ((sib = sib.previousElementSibling))
|
|
59
|
+
if (sib.tagName === elem.tagName)
|
|
60
|
+
i++
|
|
61
|
+
if (i)
|
|
62
|
+
qualifier = `:nth-of-type(${i + 1})`
|
|
63
|
+
}
|
|
64
|
+
path.push(elem.tagName + qualifier)
|
|
65
|
+
elem = elem.parentElement
|
|
66
|
+
}
|
|
67
|
+
return path.reverse().join('>')
|
|
68
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const url = new URL(import.meta.url).searchParams.get('url')
|
|
2
|
+
|
|
3
|
+
if (!url)
|
|
4
|
+
console.warn('Missing ?url=')
|
|
5
|
+
else
|
|
6
|
+
init()
|
|
7
|
+
|
|
8
|
+
function init() {
|
|
9
|
+
let conn = null
|
|
10
|
+
let timer = null
|
|
11
|
+
|
|
12
|
+
connect()
|
|
13
|
+
window.addEventListener('beforeunload', teardown)
|
|
14
|
+
|
|
15
|
+
function connect() {
|
|
16
|
+
if (conn) return
|
|
17
|
+
|
|
18
|
+
clearTimeout(timer)
|
|
19
|
+
conn = new EventSource(url)
|
|
20
|
+
|
|
21
|
+
conn.onmessage = function (event) {
|
|
22
|
+
const file = event.data
|
|
23
|
+
if (file.endsWith('.css'))
|
|
24
|
+
hotReloadCSS(file)
|
|
25
|
+
else if (file)
|
|
26
|
+
location.reload()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
conn.onerror = function () {
|
|
30
|
+
console.error('hot reload')
|
|
31
|
+
teardown()
|
|
32
|
+
timer = setTimeout(connect, 3000)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function teardown() {
|
|
37
|
+
clearTimeout(timer)
|
|
38
|
+
conn?.close()
|
|
39
|
+
conn = null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function hotReloadCSS(file) {
|
|
43
|
+
const mod = await import(`${document.baseURI}${file}?${Date.now()}`, { with: { type: 'css' } })
|
|
44
|
+
document.adoptedStyleSheets = [mod.default]
|
|
45
|
+
}
|
|
46
|
+
}
|