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.
Files changed (38) hide show
  1. package/README.md +28 -16
  2. package/index.d.ts +1 -1
  3. package/index.js +1 -1
  4. package/package.json +1 -1
  5. package/src/client/ApiCommander.js +1 -1
  6. package/src/client/ApiConstants.js +2 -2
  7. package/src/client/IndexHtml.js +18 -18
  8. package/src/client/app-header.js +3 -3
  9. package/src/client/app-payload-viewer.js +4 -8
  10. package/src/client/app-store.js +3 -34
  11. package/src/client/app.js +3 -2
  12. package/src/client/dir/dittoSplitPaths.js +25 -0
  13. package/src/client/dir/dittoSplitPaths.test.js +28 -0
  14. package/src/client/{dir-tree.js → dir/groupByFolder.js} +2 -33
  15. package/src/client/{dir-tree.test.js → dir/groupByFolder.test.js} +2 -26
  16. package/src/client/graphics.js +2 -2
  17. package/src/client/utils/LocalStorage.js +69 -0
  18. package/src/client/utils/css.js +16 -0
  19. package/src/client/{dom-utils-test.js → utils/css.test.js} +1 -1
  20. package/src/client/utils/dom.js +68 -0
  21. package/src/client/utils/watcherDev.js +46 -0
  22. package/src/server/Api.js +16 -3
  23. package/src/server/MockDispatcher.js +11 -8
  24. package/src/server/Mockaton.js +16 -8
  25. package/src/server/Mockaton.test.js +14 -9
  26. package/src/server/ProxyRelay.js +9 -3
  27. package/src/server/{utils/UrlParsers.js → UrlParsers.js} +10 -4
  28. package/src/server/{utils/UrlParsers.test.js → UrlParsers.test.js} +14 -14
  29. package/src/server/config.js +2 -0
  30. package/src/server/utils/HttpServerResponse.js +18 -39
  31. package/src/server/{WatcherDevClient.js → utils/WatcherDevClient.js} +3 -17
  32. package/src/server/utils/fs.js +1 -1
  33. package/src/server/utils/logger.js +4 -4
  34. package/src/server/utils/mime.js +11 -11
  35. package/src/server/utils/mime.test.js +15 -11
  36. package/www/src/assets/openapi.json +147 -147
  37. package/src/client/dom-utils.js +0 -154
  38. 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
- ## Overview
40
- With Mockaton, you don’t need to write code for wiring up your
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 { parseSplats } from 'mockaton'
87
+ import { parseSegments } from 'mockaton'
68
88
 
69
89
  export default async function (req: IncomingMessage, response: OutgoingMessage) {
70
- const { companyId, userId } = parseSplats(req.url, import.meta.filename)
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 parseSplats(reqUrl: string, filename: string): Record<string, string>
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 { parseSplats, parseQueryParams } from './src/server/utils/UrlParsers.js'
8
+ export { parseSegments, parseQueryParams, parseSplats } from './src/server/UrlParsers.js'
9
9
 
10
10
  export const defineConfig = opts => opts
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "mockaton",
3
3
  "description": "HTTP Mock Server",
4
4
  "type": "module",
5
- "version": "13.3.5",
5
+ "version": "13.4.0",
6
6
  "exports": {
7
7
  ".": {
8
8
  "import": "./index.js",
@@ -10,7 +10,7 @@ export class Commander {
10
10
  }
11
11
 
12
12
  /** @returns {Promise<Response>} */
13
- #patch = (api, body) => fetch(this.addr + api, {
13
+ #patch = (api, body = undefined) => fetch(this.addr + api, {
14
14
  method: 'PATCH',
15
15
  body: JSON.stringify(body)
16
16
  })
@@ -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'
@@ -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
- <meta charset="UTF-8">
14
- <base href="${API.dashboard}/">
10
+ <!DOCTYPE html>
11
+ <html lang="en-US">
12
+ <head>
13
+ <meta charset="UTF-8">
14
+ <base href="${API.dashboard}/">
15
15
 
16
- <script type="module" src="app.js"></script>
17
- <link rel="preload" href="${API.state}" as="fetch" crossorigin>
16
+ <script type="module" src="app.js"></script>
17
+ <link rel="preload" href="${API.state}" as="fetch" crossorigin>
18
18
 
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="watcherDev.js"></script>'
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
  `
@@ -1,12 +1,12 @@
1
- import { createElement as r, t, extractClassNames } from './dom-utils.js'
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, extractClassNames } from './dom-utils.js'
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
- badGateway
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
  }
@@ -1,7 +1,8 @@
1
1
  import { Commander } from './ApiCommander.js'
2
- import { dittoSplitPaths, groupByFolder } from './dir-tree.js'
2
+ import { groupByFolder } from './dir/groupByFolder.js'
3
+ import { dittoSplitPaths } from './dir/dittoSplitPaths.js'
3
4
  import { parseFilename, extractComments } from './Filename.js'
4
- import { QueryParamBool, LocalStorageSet } from './dom-utils.js'
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, classNames, extractClassNames } from './dom-utils.js'
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
- constructor() {
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, dittoSplitPaths } from './dir-tree.js'
3
+ import { groupByFolder } from './groupByFolder.js'
4
4
 
5
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
-
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' },
@@ -1,10 +1,10 @@
1
- import { createSvgElement as s } from './dom-utils.js'
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', { opacity: 0.85, 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' }),
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 { classNames, extractClassNames } from './dom-utils.js'
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
+ }