@bro-code/crawl-space 0.0.0-stage → 2026.1.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/LICENSE +21 -0
- package/README.md +260 -2
- package/dist/cjs/api.js +90 -0
- package/dist/cjs/button.js +33 -0
- package/dist/cjs/context.js +72 -0
- package/dist/cjs/crawl-space.js +76 -0
- package/dist/cjs/css.js +121 -0
- package/dist/cjs/dialog.js +106 -0
- package/dist/cjs/dom.js +17 -0
- package/dist/cjs/host.js +2 -0
- package/dist/cjs/index.js +7 -0
- package/dist/cjs/lists.js +67 -0
- package/dist/cjs/options.js +80 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/sanitize.js +21 -0
- package/dist/cjs/styles.js +69 -0
- package/dist/cjs/submit.js +96 -0
- package/dist/cjs/themes.js +89 -0
- package/dist/cjs/types.js +2 -0
- package/dist/esm/api.js +86 -0
- package/dist/esm/button.js +30 -0
- package/dist/esm/context.js +68 -0
- package/dist/esm/crawl-space.js +73 -0
- package/dist/esm/css.js +118 -0
- package/dist/esm/dialog.js +103 -0
- package/dist/esm/dom.js +14 -0
- package/dist/esm/host.js +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/lists.js +63 -0
- package/dist/esm/options.js +76 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/sanitize.js +18 -0
- package/dist/esm/styles.js +65 -0
- package/dist/esm/submit.js +93 -0
- package/dist/esm/themes.js +85 -0
- package/dist/esm/types.js +1 -0
- package/dist/types/api.d.ts +20 -0
- package/dist/types/button.d.ts +8 -0
- package/dist/types/context.d.ts +15 -0
- package/dist/types/crawl-space.d.ts +11 -0
- package/dist/types/css.d.ts +2 -0
- package/dist/types/dialog.d.ts +13 -0
- package/dist/types/dom.d.ts +6 -0
- package/dist/types/host.d.ts +17 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/lists.d.ts +9 -0
- package/dist/types/options.d.ts +23 -0
- package/dist/types/sanitize.d.ts +7 -0
- package/dist/types/styles.d.ts +14 -0
- package/dist/types/submit.d.ts +4 -0
- package/dist/types/themes.d.ts +5 -0
- package/dist/types/types.d.ts +112 -0
- package/package.json +60 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bro Code Technologies
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,261 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @bro-code/crawl-space
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@bro-code/crawl-space)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
A help desk for any web app (bug reports, active issues, Q&A) as framework-free custom elements, themed, against a documented HTTP API.
|
|
7
|
+
|
|
8
|
+
One call gives an app a dialog with three tabs:
|
|
9
|
+
|
|
10
|
+
- **Submit**: a bug or a question, with the details that help (version, page, browser, OS and anything the app adds), shown before sending under an "Include these details" checkbox the user can untick.
|
|
11
|
+
- **Active**: the signed-in user's own reports first, in every status and with the developer's answer, then everyone's reports being worked on.
|
|
12
|
+
- **Q&A**: published questions and their answers.
|
|
13
|
+
|
|
14
|
+
The elements live in a shadow DOM, so the page's CSS doesn't reach them and theirs doesn't leak out. It works with any library or none, has no runtime dependencies, and does nothing when imported. Without a `window` (server rendering), `createCrawlSpace` does nothing and returns a controller whose methods do nothing.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @bro-code/crawl-space
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
Keep the settings in the app's `bro.config.json`:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"crawlSpace": {
|
|
29
|
+
"api": "http://localhost:8080/api/crawl-space/my-app",
|
|
30
|
+
"theme": "brocode"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A browser can't read that file at runtime, so import the section and pass it in, with the parts only code can supply (TypeScript needs `resolveJsonModule`, which `moduleResolution: "bundler"` turns on; Vite keeps only the imported section in the bundle):
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { createCrawlSpace } from '@bro-code/crawl-space';
|
|
39
|
+
import { crawlSpace } from '../bro.config.json';
|
|
40
|
+
|
|
41
|
+
const help = createCrawlSpace({
|
|
42
|
+
...crawlSpace,
|
|
43
|
+
// The API's address can come from the app's env instead.
|
|
44
|
+
api: import.meta.env.VITE_API_URL ? `${import.meta.env.VITE_API_URL}/crawl-space/my-app` : crawlSpace.api,
|
|
45
|
+
getToken: () => localStorage.getItem('token'),
|
|
46
|
+
version: '1.4.0',
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A CommonJS build is included too: `const { createCrawlSpace } = require('@bro-code/crawl-space');`.
|
|
51
|
+
|
|
52
|
+
Call it once, at startup. A second call (a dev server's hot reload) replaces the options and returns the same controller.
|
|
53
|
+
|
|
54
|
+
There are three ways to open it:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
help.open(); // the Submit tab
|
|
58
|
+
help.open('qa'); // or 'active'
|
|
59
|
+
help.close();
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```html
|
|
63
|
+
<!-- Anywhere in a template; its text is the label (the title when empty). -->
|
|
64
|
+
<crawl-space-button tab="active">What's being fixed?</crawl-space-button>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{ "crawlSpace": { "api": "...", "floating": { "position": "bottom-right" } } }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`floating` adds a button fixed in a corner of the page. `<crawl-space-button floating>` does the same in a template.
|
|
72
|
+
|
|
73
|
+
## Options
|
|
74
|
+
|
|
75
|
+
### In `bro.config.json` (`CrawlSpaceConfig`)
|
|
76
|
+
|
|
77
|
+
| Field | Default | |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `api` | | Required. The API's base URL; the routes below go under it. |
|
|
80
|
+
| `paths.reports` | `/reports` | The reports route. |
|
|
81
|
+
| `paths.qa` | `/qa` | The Q&A route. |
|
|
82
|
+
| `theme` | `default` | `default`, `brocode`, `rombie`, `taut-wad` or `custom`. See [Themes](#themes). |
|
|
83
|
+
| `colors` | | With `theme: 'custom'`, any of the ten theme values over `default`'s. |
|
|
84
|
+
| `floating.position` | `bottom-right` | Adds the floating button: `bottom-right`, `bottom-left`, `top-right` or `top-left`. |
|
|
85
|
+
| `title` | `Help & bug reports` | The dialog's title and the buttons' default label. |
|
|
86
|
+
| `maxLength` | `2000` | The longest description accepted. |
|
|
87
|
+
|
|
88
|
+
### In code only (`CrawlSpaceOptions`)
|
|
89
|
+
|
|
90
|
+
| Option | |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `getToken` | `() => string \| null \| Promise<string \| null>`. The signed-in user's token, sent as `Authorization: Bearer <token>`; null when signed out. |
|
|
93
|
+
| `version` | The app's version, sent as a detail. |
|
|
94
|
+
| `scrubPath` | `(path) => string`. Takes anything private out of the page path (a room code, a token) before it is shown or sent. |
|
|
95
|
+
| `getContext` | `() => Record<string, string \| CrawlSpaceDetail>`. More details, label to value, shown and sent after the built-in ones. A `{ value, editable: true }` detail is shown in a field the user can change or clear. |
|
|
96
|
+
| `onOpenChange` | `(open) => void`, called when the dialog opens and closes (to pause an app's own keyboard or gamepad handling, say). |
|
|
97
|
+
|
|
98
|
+
An invalid `api` throws a `TypeError`. Anything else that's invalid falls back to its default with a `console.warn`.
|
|
99
|
+
|
|
100
|
+
### The details
|
|
101
|
+
|
|
102
|
+
In order: Version, Page (`location.pathname` only, never the query or hash, through `scrubPath`), Browser (name and major version), OS (name only), then `getContext()`'s entries. Each label and value is cleaned with `sanitizeText`; empty ones and repeated labels are left out. brocodetech accepts up to 12 entries, labels up to 40 characters and values up to 200, and the package keeps to that.
|
|
103
|
+
|
|
104
|
+
Only the app's own details can be editable, so the user can correct or remove something the app guessed (which game they were playing, say) while the built-in four stay as collected:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
getContext: () => ({
|
|
108
|
+
Console: { value: 'NES', editable: true },
|
|
109
|
+
'Last game': { value: lastGameTitle, editable: true },
|
|
110
|
+
}),
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The field is sent as it reads when the report goes, cleaned the same way; a field left empty leaves that detail out. Unticking "Include these details" disables the fields and sends none of the details. An edit lasts while the dialog switches tabs, as long as the app's value for it stays the same; a sent report puts the app's values back.
|
|
114
|
+
|
|
115
|
+
## Themes
|
|
116
|
+
|
|
117
|
+
| | `default` | `brocode` | `rombie` | `taut-wad` |
|
|
118
|
+
|---|---|---|---|---|
|
|
119
|
+
| `background` | `#121417` | `#181818` | `#0a0a12` | `#181818` |
|
|
120
|
+
| `surface` | `#1c1f24` | `#222222` | `#15141f` | `#222222` |
|
|
121
|
+
| `text` | `#e8eaed` | `#ffffff` | `#f1eef9` | `#ffffff` |
|
|
122
|
+
| `muted` | `#9aa0a6` | `#b0b0b0` | `#9a90bd` | `#b0b0b0` |
|
|
123
|
+
| `border` | `#33373d` | `#3c3c3c` | `#302c47` | `#3c3c3c` |
|
|
124
|
+
| `accent` | `#5b9cff` | `#7b9981` | `#ff2d78` | `#7b9981` |
|
|
125
|
+
| `accentText` | `#0b1220` | `#ffffff` | `#12060d` | `#ffffff` |
|
|
126
|
+
| `error` | `#ff6b6b` | `#ff5252` | `#ff5c5c` | `#ff5252` |
|
|
127
|
+
| `success` | `#6fcf97` | `#96be4d` | `#00e5ff` | `#96be4d` |
|
|
128
|
+
| `font` | `system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | `Roboto, sans-serif` | `system-ui, 'Segoe UI', Roboto, sans-serif` | `Roboto, sans-serif` |
|
|
129
|
+
|
|
130
|
+
`custom` starts from `default`:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{ "crawlSpace": { "api": "...", "theme": "custom", "colors": { "accent": "#e91e63", "font": "Georgia, serif" } } }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Each color is checked with `CSS.supports('color', ...)` and the font with `CSS.supports('font-family', ...)`; a missing or invalid one keeps `default`'s, with a warning for the invalid ones. An unknown theme is `default`, with a warning.
|
|
137
|
+
|
|
138
|
+
## Restyling
|
|
139
|
+
|
|
140
|
+
The theme is set as custom properties on each element, and a page's own rules win over them:
|
|
141
|
+
|
|
142
|
+
```css
|
|
143
|
+
crawl-space-button,
|
|
144
|
+
crawl-space-dialog {
|
|
145
|
+
--cs-accent: #e91e63;
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The properties are `--cs-background`, `--cs-surface`, `--cs-text`, `--cs-muted`, `--cs-border`, `--cs-accent`, `--cs-accent-text`, `--cs-error`, `--cs-success` and `--cs-font`. The button itself is a part:
|
|
150
|
+
|
|
151
|
+
```css
|
|
152
|
+
crawl-space-button::part(button) {
|
|
153
|
+
border-radius: 0;
|
|
154
|
+
text-transform: uppercase;
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Small screens and touch
|
|
159
|
+
|
|
160
|
+
Two rules apply in every app, whatever the theme, and are not options:
|
|
161
|
+
|
|
162
|
+
- **Below 600px wide**, the dialog fills the screen: the full width, and the full height in `dvh`, so a phone's browser bars don't cut it off, with no border, rounded corners or shadow. Each tab still scrolls inside it. From 600px it is the card.
|
|
163
|
+
- **On touch input** (`(hover: none)`), the close button (48 by 48), the tabs, the Bug and Question toggle, the "Include these details" row and its checkbox (24px), Send, Try again and the Q&A questions are at least 48px tall, with at least 8px between neighbours. With a mouse they keep their smaller sizes.
|
|
164
|
+
|
|
165
|
+
## Setup per library
|
|
166
|
+
|
|
167
|
+
The elements are plain custom elements. Tell a framework's template compiler they aren't its components:
|
|
168
|
+
|
|
169
|
+
**Vue** (`vite.config.ts`):
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
vue({
|
|
173
|
+
template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('crawl-space-') } },
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Angular**: add `CUSTOM_ELEMENTS_SCHEMA` to the `schemas` of each standalone component (or module) whose template uses `<crawl-space-button>`.
|
|
178
|
+
|
|
179
|
+
**React 19** renders custom elements as they are; declare the button for JSX once, in a `.d.ts`:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
|
|
183
|
+
|
|
184
|
+
declare module 'react' {
|
|
185
|
+
namespace JSX {
|
|
186
|
+
interface IntrinsicElements {
|
|
187
|
+
'crawl-space-button': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
|
|
188
|
+
tab?: 'submit' | 'active' | 'qa';
|
|
189
|
+
floating?: boolean;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## HTTP API
|
|
197
|
+
|
|
198
|
+
Any server can back it. brocodetech's `/api/crawl-space/:app` routes are the reference implementation. Every request sends `Accept: application/json`, and `Authorization: Bearer <token>` when `getToken` returns a token.
|
|
199
|
+
|
|
200
|
+
### `POST {api}{paths.reports}`
|
|
201
|
+
|
|
202
|
+
Sends a report.
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{ "type": "bug", "description": "The save is gone", "context": { "Version": "1.4.0", "Page": "/play" } }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- `type` is `bug` or `question`. `description` is cleaned with `sanitizeText` and at most `maxLength` long. `context` is left out when the user unticks the details.
|
|
209
|
+
- `201` with `{ "id": 12 }` on success. `429` tells the user to try again in a minute; any other status is a failure, and the text is kept so they can resend.
|
|
210
|
+
|
|
211
|
+
### `GET {api}{paths.reports}`
|
|
212
|
+
|
|
213
|
+
```json
|
|
214
|
+
{ "mine": [Report], "active": [Report] }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- `mine` is the signed-in user's reports in every status (brocodetech leaves out ones resolved or dismissed more than 30 days ago), empty when there's no token. Its `answer` is shown.
|
|
218
|
+
- `active` is everyone else's reports being worked on.
|
|
219
|
+
|
|
220
|
+
A `Report` is `{ id, type, status, description, answer, createdAt, updatedAt }`: `id` a number or string, `status` one of `new`, `in_progress`, `resolved` and `dismissed`, `answer` a string or null, and the dates ISO 8601.
|
|
221
|
+
|
|
222
|
+
### `GET {api}{paths.qa}`
|
|
223
|
+
|
|
224
|
+
An array of `{ id, question, answer, updatedAt }`.
|
|
225
|
+
|
|
226
|
+
### What the client trusts
|
|
227
|
+
|
|
228
|
+
Responses are checked: a malformed item is dropped, fields the contract doesn't name are ignored, and a malformed body is a failed load. Everything from the API and from users is rendered as text, never markup. The server stays the authority: it should validate and limit what it accepts, limit how often an address can post, and never put a reporter's account, email or details in a public response.
|
|
229
|
+
|
|
230
|
+
An API on another origin than the site must allow the site's origin in CORS, for `GET` and `POST` with the `Content-Type` and `Authorization` headers.
|
|
231
|
+
|
|
232
|
+
## API
|
|
233
|
+
|
|
234
|
+
| Export | |
|
|
235
|
+
|---|---|
|
|
236
|
+
| `createCrawlSpace(options)` | Sets everything up and returns `{ open(tab?), close() }`. |
|
|
237
|
+
| `sanitizeText(text, maxLength)` | Cleans text a user typed: NFC, no control, zero-width or bidi characters, `\n` line ends, runs of three or more blank lines collapsed to one, trimmed, and at most `maxLength` UTF-16 units (never ending in half of a surrogate pair). |
|
|
238
|
+
| Types | `CrawlSpaceConfig`, `CrawlSpaceOptions`, `CrawlSpaceController`, `CrawlSpaceTab`, `CrawlSpaceTheme`, `CrawlSpaceColors`, `CrawlSpacePosition`, `CrawlSpaceDetail`, `CrawlSpaceContextEntry`, `CrawlSpaceSubmission`, `CrawlSpaceReport`, `CrawlSpaceReports`, `CrawlSpaceReportType`, `CrawlSpaceReportStatus`, `CrawlSpaceQaItem`. |
|
|
239
|
+
|
|
240
|
+
The dialog is a native modal `<dialog>`: it sits above everything on the page, Escape closes it, focus stays inside while it's open and goes back to what opened it. The arrow keys, Home and End move between the tabs.
|
|
241
|
+
|
|
242
|
+
## License
|
|
243
|
+
|
|
244
|
+
MIT - see [LICENSE](./LICENSE).
|
|
245
|
+
|
|
246
|
+
## Changelog
|
|
247
|
+
|
|
248
|
+
### [2026.1.0] - 2026-11-11
|
|
249
|
+
|
|
250
|
+
#### Changed
|
|
251
|
+
- Below 600px wide the dialog fills the screen (full width, `100dvh` tall, no border, radius or shadow), and on touch input (`(hover: none)`) its close button, tabs, Bug and Question toggle, details row and checkbox, Send, Try again and Q&A questions are at least 48px tall with at least 8px between them. Both apply in every app; a mouse keeps today's sizes.
|
|
252
|
+
|
|
253
|
+
### [2026.0.0] - 2026-11-04
|
|
254
|
+
|
|
255
|
+
#### Added
|
|
256
|
+
- Initial release.
|
|
257
|
+
- `createCrawlSpace(options)`, with `open(tab?)` and `close()`, the `<crawl-space-button>` element and the floating button.
|
|
258
|
+
- The Submit, Active and Q&A tabs, against the documented HTTP API.
|
|
259
|
+
- Details from `getContext` the user can change or clear before sending (`{ value, editable: true }`).
|
|
260
|
+
- The `default`, `brocode`, `rombie`, `taut-wad` and `custom` themes, the `--cs-*` properties and `::part(button)`.
|
|
261
|
+
- `sanitizeText(text, maxLength)`.
|
package/dist/cjs/api.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.joinUrl = joinUrl;
|
|
4
|
+
exports.createApiClient = createApiClient;
|
|
5
|
+
const TYPES = ['bug', 'question'];
|
|
6
|
+
const STATUSES = ['new', 'in_progress', 'resolved', 'dismissed'];
|
|
7
|
+
const FAILED = { ok: false, reason: 'failed' };
|
|
8
|
+
function joinUrl(base, path) {
|
|
9
|
+
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
10
|
+
}
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function isId(value) {
|
|
15
|
+
return (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value !== '');
|
|
16
|
+
}
|
|
17
|
+
function isDate(value) {
|
|
18
|
+
return typeof value === 'string' && !Number.isNaN(Date.parse(value));
|
|
19
|
+
}
|
|
20
|
+
function isOneOf(list, value) {
|
|
21
|
+
return list.includes(value);
|
|
22
|
+
}
|
|
23
|
+
function toReport(item) {
|
|
24
|
+
if (!isRecord(item))
|
|
25
|
+
return null;
|
|
26
|
+
const { id, type, status, description, answer, createdAt, updatedAt } = item;
|
|
27
|
+
if (!isId(id) || !isOneOf(TYPES, type) || !isOneOf(STATUSES, status) || typeof description !== 'string')
|
|
28
|
+
return null;
|
|
29
|
+
if (answer != null && typeof answer !== 'string')
|
|
30
|
+
return null;
|
|
31
|
+
if (!isDate(createdAt) || !isDate(updatedAt))
|
|
32
|
+
return null;
|
|
33
|
+
return { id, type, status, description, answer: answer ?? null, createdAt, updatedAt };
|
|
34
|
+
}
|
|
35
|
+
function toQaItem(item) {
|
|
36
|
+
if (!isRecord(item))
|
|
37
|
+
return null;
|
|
38
|
+
const { id, question, answer, updatedAt } = item;
|
|
39
|
+
if (!isId(id) || typeof question !== 'string' || typeof answer !== 'string' || !isDate(updatedAt))
|
|
40
|
+
return null;
|
|
41
|
+
return { id, question, answer, updatedAt };
|
|
42
|
+
}
|
|
43
|
+
function listOf(value, toItem) {
|
|
44
|
+
if (!Array.isArray(value))
|
|
45
|
+
return null;
|
|
46
|
+
return value.map(toItem).filter((item) => item !== null);
|
|
47
|
+
}
|
|
48
|
+
function toReports(body) {
|
|
49
|
+
if (!isRecord(body))
|
|
50
|
+
return null;
|
|
51
|
+
const mine = listOf(body.mine, toReport);
|
|
52
|
+
const active = listOf(body.active, toReport);
|
|
53
|
+
return mine && active ? { mine, active } : null;
|
|
54
|
+
}
|
|
55
|
+
function toCreated(body) {
|
|
56
|
+
return isRecord(body) && isId(body.id) ? { id: body.id } : null;
|
|
57
|
+
}
|
|
58
|
+
function createApiClient(options) {
|
|
59
|
+
const reportsUrl = joinUrl(options.api, options.paths?.reports ?? '/reports');
|
|
60
|
+
const qaUrl = joinUrl(options.api, options.paths?.qa ?? '/qa');
|
|
61
|
+
async function request(url, parse, body) {
|
|
62
|
+
try {
|
|
63
|
+
const headers = { Accept: 'application/json' };
|
|
64
|
+
if (body !== undefined)
|
|
65
|
+
headers['Content-Type'] = 'application/json';
|
|
66
|
+
const token = await options.getToken?.();
|
|
67
|
+
if (typeof token === 'string' && token !== '')
|
|
68
|
+
headers.Authorization = `Bearer ${token}`;
|
|
69
|
+
const response = await fetch(url, {
|
|
70
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
71
|
+
headers,
|
|
72
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
73
|
+
});
|
|
74
|
+
if (response.status === 429)
|
|
75
|
+
return { ok: false, reason: 'rate-limited' };
|
|
76
|
+
if (!response.ok)
|
|
77
|
+
return FAILED;
|
|
78
|
+
const value = parse(await response.json());
|
|
79
|
+
return value === null ? FAILED : { ok: true, value };
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return FAILED;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
submitReport: (submission) => request(reportsUrl, toCreated, submission),
|
|
87
|
+
getReports: () => request(reportsUrl, toReports),
|
|
88
|
+
getQa: () => request(qaUrl, (body) => listOf(body, toQaItem)),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.defineButton = defineButton;
|
|
4
|
+
const css_js_1 = require("./css.js");
|
|
5
|
+
const dom_js_1 = require("./dom.js");
|
|
6
|
+
const options_js_1 = require("./options.js");
|
|
7
|
+
function defineButton(getHost) {
|
|
8
|
+
return class CrawlSpaceButton extends HTMLElement {
|
|
9
|
+
root;
|
|
10
|
+
label = document.createTextNode('');
|
|
11
|
+
constructor() {
|
|
12
|
+
super();
|
|
13
|
+
this.root = this.attachShadow({ mode: 'open', delegatesFocus: true });
|
|
14
|
+
const button = (0, dom_js_1.h)('button', { type: 'button', part: 'button' }, (0, dom_js_1.h)('slot', {}, this.label));
|
|
15
|
+
button.addEventListener('click', () => getHost().open((0, options_js_1.toTab)(this.getAttribute('tab'))));
|
|
16
|
+
this.root.append(button);
|
|
17
|
+
}
|
|
18
|
+
connectedCallback() {
|
|
19
|
+
getHost().connected.add(this);
|
|
20
|
+
this.refresh();
|
|
21
|
+
}
|
|
22
|
+
disconnectedCallback() {
|
|
23
|
+
getHost().connected.delete(this);
|
|
24
|
+
}
|
|
25
|
+
refresh() {
|
|
26
|
+
const host = getHost();
|
|
27
|
+
host.styles.apply(this.root, css_js_1.BUTTON_CSS);
|
|
28
|
+
this.label.data = host.options.title;
|
|
29
|
+
if (this.hasAttribute('floating'))
|
|
30
|
+
this.dataset.position = host.options.floating ?? 'bottom-right';
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CONTEXT_LIMITS = void 0;
|
|
4
|
+
exports.collectContext = collectContext;
|
|
5
|
+
const sanitize_js_1 = require("./sanitize.js");
|
|
6
|
+
exports.CONTEXT_LIMITS = {
|
|
7
|
+
maxEntries: 12,
|
|
8
|
+
maxKeyLength: 40,
|
|
9
|
+
maxValueLength: 200,
|
|
10
|
+
};
|
|
11
|
+
const BROWSERS = [
|
|
12
|
+
['Edge', /Edg\/(\d+)/],
|
|
13
|
+
['Firefox', /Firefox\/(\d+)/],
|
|
14
|
+
['Chrome', /Chrome\/(\d+)/],
|
|
15
|
+
['Safari', /Version\/(\d+).*Safari/],
|
|
16
|
+
];
|
|
17
|
+
const SYSTEMS = [
|
|
18
|
+
['Windows', /Windows/],
|
|
19
|
+
['iOS', /iPhone|iPad|iPod/],
|
|
20
|
+
['Android', /Android/],
|
|
21
|
+
['ChromeOS', /CrOS/],
|
|
22
|
+
['macOS', /Macintosh|Mac OS X/],
|
|
23
|
+
['Linux', /Linux/],
|
|
24
|
+
];
|
|
25
|
+
function browserName(userAgent) {
|
|
26
|
+
for (const [name, pattern] of BROWSERS) {
|
|
27
|
+
const match = pattern.exec(userAgent);
|
|
28
|
+
if (match)
|
|
29
|
+
return `${name} ${match[1]}`;
|
|
30
|
+
}
|
|
31
|
+
return 'Other';
|
|
32
|
+
}
|
|
33
|
+
function systemName(userAgent) {
|
|
34
|
+
return SYSTEMS.find(([, pattern]) => pattern.test(userAgent))?.[0] ?? 'Other';
|
|
35
|
+
}
|
|
36
|
+
function collectContext(options) {
|
|
37
|
+
const raw = [['Version', options.version, false]];
|
|
38
|
+
if (typeof location !== 'undefined') {
|
|
39
|
+
const path = location.pathname;
|
|
40
|
+
raw.push(['Page', options.scrubPath ? options.scrubPath(path) : path, false]);
|
|
41
|
+
}
|
|
42
|
+
if (typeof navigator !== 'undefined') {
|
|
43
|
+
raw.push(['Browser', browserName(navigator.userAgent), false], ['OS', systemName(navigator.userAgent), false]);
|
|
44
|
+
}
|
|
45
|
+
const extras = options.getContext?.();
|
|
46
|
+
if (typeof extras === 'object' && extras !== null) {
|
|
47
|
+
for (const [label, item] of Object.entries(extras)) {
|
|
48
|
+
if (typeof item === 'object' && item !== null) {
|
|
49
|
+
const detail = item;
|
|
50
|
+
raw.push([label, detail.value, detail.editable === true]);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
raw.push([label, item, false]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const entries = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const [rawLabel, rawValue, editable] of raw) {
|
|
60
|
+
if (entries.length === exports.CONTEXT_LIMITS.maxEntries)
|
|
61
|
+
break;
|
|
62
|
+
if (typeof rawValue !== 'string')
|
|
63
|
+
continue;
|
|
64
|
+
const label = (0, sanitize_js_1.sanitizeText)(rawLabel, exports.CONTEXT_LIMITS.maxKeyLength);
|
|
65
|
+
const value = (0, sanitize_js_1.sanitizeText)(rawValue, exports.CONTEXT_LIMITS.maxValueLength);
|
|
66
|
+
if (label === '' || value === '' || seen.has(label))
|
|
67
|
+
continue;
|
|
68
|
+
seen.add(label);
|
|
69
|
+
entries.push({ label, value, editable });
|
|
70
|
+
}
|
|
71
|
+
return entries;
|
|
72
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createCrawlSpace = createCrawlSpace;
|
|
4
|
+
const api_js_1 = require("./api.js");
|
|
5
|
+
const button_js_1 = require("./button.js");
|
|
6
|
+
const dialog_js_1 = require("./dialog.js");
|
|
7
|
+
const options_js_1 = require("./options.js");
|
|
8
|
+
const styles_js_1 = require("./styles.js");
|
|
9
|
+
const KEY = Symbol.for('@bro-code/crawl-space');
|
|
10
|
+
const NOOP = { open: () => undefined, close: () => undefined };
|
|
11
|
+
function getState() {
|
|
12
|
+
const state = window[KEY];
|
|
13
|
+
if (!state)
|
|
14
|
+
throw new Error('[crawl-space] createCrawlSpace has not been called.');
|
|
15
|
+
return state;
|
|
16
|
+
}
|
|
17
|
+
function whenBody(run) {
|
|
18
|
+
if (document.body)
|
|
19
|
+
run(document.body);
|
|
20
|
+
else
|
|
21
|
+
document.addEventListener('DOMContentLoaded', () => run(document.body), { once: true });
|
|
22
|
+
}
|
|
23
|
+
function syncFloating(state) {
|
|
24
|
+
if (state.options.floating && !state.floating) {
|
|
25
|
+
const button = document.createElement('crawl-space-button');
|
|
26
|
+
button.setAttribute('floating', '');
|
|
27
|
+
state.floating = button;
|
|
28
|
+
whenBody((body) => body.append(button));
|
|
29
|
+
}
|
|
30
|
+
else if (!state.options.floating && state.floating) {
|
|
31
|
+
state.floating.remove();
|
|
32
|
+
state.floating = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function openDialog(state, tab) {
|
|
36
|
+
state.dialog ??= document.createElement('crawl-space-dialog');
|
|
37
|
+
if (!state.dialog.isConnected)
|
|
38
|
+
document.body.append(state.dialog);
|
|
39
|
+
state.dialog.showTab(tab);
|
|
40
|
+
}
|
|
41
|
+
function createCrawlSpace(options) {
|
|
42
|
+
if (typeof window === 'undefined' || typeof customElements === 'undefined')
|
|
43
|
+
return NOOP;
|
|
44
|
+
const resolved = (0, options_js_1.resolveOptions)(options);
|
|
45
|
+
const target = window;
|
|
46
|
+
const existing = target[KEY];
|
|
47
|
+
if (existing) {
|
|
48
|
+
existing.options = resolved;
|
|
49
|
+
existing.api = (0, api_js_1.createApiClient)(resolved);
|
|
50
|
+
existing.styles.setColors(resolved.colors);
|
|
51
|
+
for (const element of existing.connected)
|
|
52
|
+
element.refresh();
|
|
53
|
+
syncFloating(existing);
|
|
54
|
+
return existing.controller;
|
|
55
|
+
}
|
|
56
|
+
const state = {
|
|
57
|
+
options: resolved,
|
|
58
|
+
api: (0, api_js_1.createApiClient)(resolved),
|
|
59
|
+
styles: (0, styles_js_1.createStyles)(resolved.colors),
|
|
60
|
+
connected: new Set(),
|
|
61
|
+
dialog: null,
|
|
62
|
+
floating: null,
|
|
63
|
+
open: (tab) => openDialog(state, tab),
|
|
64
|
+
controller: {
|
|
65
|
+
open: (tab) => openDialog(state, (0, options_js_1.toTab)(tab)),
|
|
66
|
+
close: () => state.dialog?.hide(),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
target[KEY] = state;
|
|
70
|
+
if (!customElements.get('crawl-space-dialog'))
|
|
71
|
+
customElements.define('crawl-space-dialog', (0, dialog_js_1.defineDialog)(getState));
|
|
72
|
+
if (!customElements.get('crawl-space-button'))
|
|
73
|
+
customElements.define('crawl-space-button', (0, button_js_1.defineButton)(getState));
|
|
74
|
+
syncFloating(state);
|
|
75
|
+
return state.controller;
|
|
76
|
+
}
|