@dorsk/hmm 0.0.0-stage → 0.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 +162 -2
- package/dist/Hmm.svelte +650 -0
- package/dist/Hmm.svelte.d.ts +11 -0
- package/dist/core.d.ts +33 -0
- package/dist/core.js +50 -0
- package/dist/draft.d.ts +12 -0
- package/dist/draft.js +44 -0
- package/dist/envelope.d.ts +103 -0
- package/dist/envelope.js +218 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +32 -0
- package/dist/inspect.d.ts +4 -0
- package/dist/inspect.js +75 -0
- package/dist/labels.d.ts +36 -0
- package/dist/labels.js +35 -0
- package/dist/picker.d.ts +2 -0
- package/dist/picker.js +4 -0
- package/dist/redact.d.ts +5 -0
- package/dist/redact.js +64 -0
- package/dist/shot.d.ts +10 -0
- package/dist/shot.js +61 -0
- package/dist/transport.d.ts +11 -0
- package/dist/transport.js +71 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +82 -5
- package/schema/envelope.v1.json +163 -0
package/dist/core.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { LIMITS } from "./envelope.js";
|
|
2
|
+
import { httpTransport } from "./transport.js";
|
|
3
|
+
export const uid = () => {
|
|
4
|
+
const c = globalThis.crypto;
|
|
5
|
+
if (c.randomUUID)
|
|
6
|
+
return c.randomUUID();
|
|
7
|
+
return [...c.getRandomValues(new Uint8Array(16))]
|
|
8
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
9
|
+
.join('');
|
|
10
|
+
};
|
|
11
|
+
export function storageOf(get) {
|
|
12
|
+
try {
|
|
13
|
+
const storage = get();
|
|
14
|
+
storage.getItem('hmm');
|
|
15
|
+
return storage;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function clamp(envelope) {
|
|
22
|
+
return {
|
|
23
|
+
...envelope,
|
|
24
|
+
comment: envelope.comment.slice(0, LIMITS.comment),
|
|
25
|
+
page: envelope.page && { ...envelope.page, url: envelope.page.url.slice(0, LIMITS.url) },
|
|
26
|
+
items: envelope.items.map((item) => ({
|
|
27
|
+
...item,
|
|
28
|
+
url: item.url.slice(0, LIMITS.url),
|
|
29
|
+
comment: item.comment.slice(0, LIMITS.comment),
|
|
30
|
+
})),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Throws when the options cannot deliver a report. */
|
|
34
|
+
export function transportOf(options) {
|
|
35
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(options?.projectKey ?? ''))
|
|
36
|
+
throw new Error('hmm: projectKey must be 1 to 128 letters, digits, dots, dashes or underscores');
|
|
37
|
+
const transport = options.transport ??
|
|
38
|
+
(options.endpoint ? httpTransport(options.endpoint, options.projectKey) : null);
|
|
39
|
+
if (!transport)
|
|
40
|
+
throw new Error('hmm: either endpoint or transport is required');
|
|
41
|
+
return transport;
|
|
42
|
+
}
|
|
43
|
+
/** The report as shown to the user: image data replaced by its size. */
|
|
44
|
+
export const readable = (envelope) => JSON.stringify({
|
|
45
|
+
...envelope,
|
|
46
|
+
attachments: envelope.attachments.map((a) => ({
|
|
47
|
+
...a,
|
|
48
|
+
data: `<${Math.round((a.data.length * 3) / 4 / 1024)} KB of PNG>`,
|
|
49
|
+
})),
|
|
50
|
+
}, null, 2);
|
package/dist/draft.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Attachment, type Category, type Item } from './envelope.ts';
|
|
2
|
+
export type Draft = {
|
|
3
|
+
category: Category;
|
|
4
|
+
comment: string;
|
|
5
|
+
items: Item[];
|
|
6
|
+
attachments: Attachment[];
|
|
7
|
+
};
|
|
8
|
+
export declare const emptyDraft: () => Draft;
|
|
9
|
+
export declare const isEmpty: (draft: Draft) => boolean;
|
|
10
|
+
export declare function loadDraft(storage: Storage | null, key: string): Draft;
|
|
11
|
+
/** Best effort: a draft too large for the storage quota stays in memory only. */
|
|
12
|
+
export declare function saveDraft(storage: Storage | null, key: string, draft: Draft): void;
|
package/dist/draft.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { CATEGORIES, validateEnvelope, } from "./envelope.js";
|
|
2
|
+
export const emptyDraft = () => ({
|
|
3
|
+
category: 'idea',
|
|
4
|
+
comment: '',
|
|
5
|
+
items: [],
|
|
6
|
+
attachments: [],
|
|
7
|
+
});
|
|
8
|
+
export const isEmpty = (draft) => !draft.comment.trim() && draft.items.length === 0;
|
|
9
|
+
const wellFormed = (draft) => validateEnvelope({
|
|
10
|
+
v: 1,
|
|
11
|
+
id: 'draft',
|
|
12
|
+
kind: 'feedback',
|
|
13
|
+
project: 'draft',
|
|
14
|
+
createdAt: '1970-01-01T00:00:00Z',
|
|
15
|
+
client: { name: '', version: '' },
|
|
16
|
+
context: {},
|
|
17
|
+
comment: draft.comment,
|
|
18
|
+
items: draft.items,
|
|
19
|
+
attachments: draft.attachments,
|
|
20
|
+
}).ok;
|
|
21
|
+
export function loadDraft(storage, key) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(storage?.getItem(key) ?? 'null');
|
|
24
|
+
if (parsed && CATEGORIES.includes(parsed.category) && wellFormed(parsed))
|
|
25
|
+
return parsed;
|
|
26
|
+
}
|
|
27
|
+
catch { }
|
|
28
|
+
return emptyDraft();
|
|
29
|
+
}
|
|
30
|
+
/** Best effort: a draft too large for the storage quota stays in memory only. */
|
|
31
|
+
export function saveDraft(storage, key, draft) {
|
|
32
|
+
try {
|
|
33
|
+
if (isEmpty(draft))
|
|
34
|
+
storage?.removeItem(key);
|
|
35
|
+
else
|
|
36
|
+
storage?.setItem(key, JSON.stringify(draft));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
try {
|
|
40
|
+
storage?.removeItem(key);
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export declare const ENVELOPE_VERSION: 1;
|
|
2
|
+
export declare const KINDS: readonly ["feedback", "error", "alert"];
|
|
3
|
+
export declare const CATEGORIES: readonly ["idea", "problem", "question", "other"];
|
|
4
|
+
export type Kind = (typeof KINDS)[number];
|
|
5
|
+
export type Category = (typeof CATEGORIES)[number];
|
|
6
|
+
export declare const LIMITS: {
|
|
7
|
+
readonly comment: 5000;
|
|
8
|
+
readonly items: 20;
|
|
9
|
+
readonly attachments: 10;
|
|
10
|
+
readonly attachmentChars: 2800000;
|
|
11
|
+
readonly attachmentSide: 8192;
|
|
12
|
+
readonly contextChars: 16000;
|
|
13
|
+
readonly contextKeys: 50;
|
|
14
|
+
readonly url: 2000;
|
|
15
|
+
readonly selector: 1000;
|
|
16
|
+
readonly text: 400;
|
|
17
|
+
readonly html: 2000;
|
|
18
|
+
readonly attrs: 40;
|
|
19
|
+
readonly attrName: 100;
|
|
20
|
+
readonly attrValue: 400;
|
|
21
|
+
readonly short: 500;
|
|
22
|
+
};
|
|
23
|
+
export type Rect = {
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
};
|
|
29
|
+
export type Target = {
|
|
30
|
+
selector: string;
|
|
31
|
+
tag: string;
|
|
32
|
+
text: string;
|
|
33
|
+
html: string;
|
|
34
|
+
attrs: Record<string, string>;
|
|
35
|
+
rect: Rect;
|
|
36
|
+
source?: {
|
|
37
|
+
file: string;
|
|
38
|
+
line: number;
|
|
39
|
+
column: number;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export type ElementItem = {
|
|
43
|
+
id: string;
|
|
44
|
+
type: 'element';
|
|
45
|
+
url: string;
|
|
46
|
+
comment: string;
|
|
47
|
+
target: Target;
|
|
48
|
+
attachment?: string;
|
|
49
|
+
};
|
|
50
|
+
export type ScreenshotItem = {
|
|
51
|
+
id: string;
|
|
52
|
+
type: 'screenshot';
|
|
53
|
+
url: string;
|
|
54
|
+
comment: string;
|
|
55
|
+
attachment: string;
|
|
56
|
+
};
|
|
57
|
+
export type Item = ElementItem | ScreenshotItem;
|
|
58
|
+
export type Attachment = {
|
|
59
|
+
id: string;
|
|
60
|
+
mime: 'image/png';
|
|
61
|
+
width: number;
|
|
62
|
+
height: number;
|
|
63
|
+
/** Base64 without a data URL prefix. */
|
|
64
|
+
data: string;
|
|
65
|
+
};
|
|
66
|
+
export type Page = {
|
|
67
|
+
url: string;
|
|
68
|
+
title: string;
|
|
69
|
+
viewport: {
|
|
70
|
+
width: number;
|
|
71
|
+
height: number;
|
|
72
|
+
};
|
|
73
|
+
userAgent: string;
|
|
74
|
+
language: string;
|
|
75
|
+
};
|
|
76
|
+
export type Envelope = {
|
|
77
|
+
v: typeof ENVELOPE_VERSION;
|
|
78
|
+
id: string;
|
|
79
|
+
kind: Kind;
|
|
80
|
+
category?: Category;
|
|
81
|
+
/** Groups repeats of the same error or alert. */
|
|
82
|
+
fingerprint?: string;
|
|
83
|
+
project: string;
|
|
84
|
+
createdAt: string;
|
|
85
|
+
client: {
|
|
86
|
+
name: string;
|
|
87
|
+
version: string;
|
|
88
|
+
};
|
|
89
|
+
page?: Page;
|
|
90
|
+
context: Record<string, unknown>;
|
|
91
|
+
comment: string;
|
|
92
|
+
items: Item[];
|
|
93
|
+
attachments: Attachment[];
|
|
94
|
+
};
|
|
95
|
+
export type Validation = {
|
|
96
|
+
ok: true;
|
|
97
|
+
envelope: Envelope;
|
|
98
|
+
} | {
|
|
99
|
+
ok: false;
|
|
100
|
+
errors: string[];
|
|
101
|
+
};
|
|
102
|
+
/** Strict structural check, shared by the widget before sending and by servers on receipt. */
|
|
103
|
+
export declare function validateEnvelope(data: unknown): Validation;
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
export const ENVELOPE_VERSION = 1;
|
|
2
|
+
export const KINDS = ['feedback', 'error', 'alert'];
|
|
3
|
+
export const CATEGORIES = ['idea', 'problem', 'question', 'other'];
|
|
4
|
+
export const LIMITS = {
|
|
5
|
+
comment: 5000,
|
|
6
|
+
items: 20,
|
|
7
|
+
attachments: 10,
|
|
8
|
+
attachmentChars: 2_800_000,
|
|
9
|
+
attachmentSide: 8192,
|
|
10
|
+
contextChars: 16_000,
|
|
11
|
+
contextKeys: 50,
|
|
12
|
+
url: 2000,
|
|
13
|
+
selector: 1000,
|
|
14
|
+
text: 400,
|
|
15
|
+
html: 2000,
|
|
16
|
+
attrs: 40,
|
|
17
|
+
attrName: 100,
|
|
18
|
+
attrValue: 400,
|
|
19
|
+
short: 500,
|
|
20
|
+
};
|
|
21
|
+
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
22
|
+
const PROJECT = /^[A-Za-z0-9._-]{1,128}$/;
|
|
23
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
24
|
+
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
25
|
+
class Check {
|
|
26
|
+
errors = [];
|
|
27
|
+
fail(path, message) {
|
|
28
|
+
this.errors.push(`${path}: ${message}`);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
object(value, path, required, optional = []) {
|
|
32
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
33
|
+
this.fail(path, 'must be an object');
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const obj = value;
|
|
37
|
+
for (const key of required)
|
|
38
|
+
if (!(key in obj))
|
|
39
|
+
this.fail(`${path}.${key}`, 'is required');
|
|
40
|
+
for (const key of Object.keys(obj)) {
|
|
41
|
+
if (!required.includes(key) && !optional.includes(key))
|
|
42
|
+
this.fail(`${path}.${key}`, 'is not allowed');
|
|
43
|
+
}
|
|
44
|
+
return obj;
|
|
45
|
+
}
|
|
46
|
+
string(value, path, max, pattern) {
|
|
47
|
+
if (typeof value !== 'string')
|
|
48
|
+
return this.fail(path, 'must be a string');
|
|
49
|
+
if (value.length > max)
|
|
50
|
+
return this.fail(path, `must be at most ${max} characters`);
|
|
51
|
+
if (pattern && !pattern.test(value))
|
|
52
|
+
return this.fail(path, 'has an invalid format');
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
int(value, path, min, max) {
|
|
56
|
+
if (typeof value !== 'number' || !Number.isInteger(value))
|
|
57
|
+
return this.fail(path, 'must be an integer');
|
|
58
|
+
if (value < min || value > max)
|
|
59
|
+
return this.fail(path, `must be between ${min} and ${max}`);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
oneOf(value, path, values) {
|
|
63
|
+
return values.includes(value) || this.fail(path, `must be one of ${values.join(', ')}`);
|
|
64
|
+
}
|
|
65
|
+
array(value, path, max) {
|
|
66
|
+
if (!Array.isArray(value)) {
|
|
67
|
+
this.fail(path, 'must be an array');
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (value.length > max)
|
|
71
|
+
this.fail(path, `must have at most ${max} entries`);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const COORD = 1_000_000;
|
|
76
|
+
function checkTarget(c, value, path) {
|
|
77
|
+
const t = c.object(value, path, ['selector', 'tag', 'text', 'html', 'attrs', 'rect'], ['source']);
|
|
78
|
+
if (!t)
|
|
79
|
+
return;
|
|
80
|
+
c.string(t.selector, `${path}.selector`, LIMITS.selector);
|
|
81
|
+
c.string(t.tag, `${path}.tag`, 100);
|
|
82
|
+
c.string(t.text, `${path}.text`, LIMITS.text);
|
|
83
|
+
c.string(t.html, `${path}.html`, LIMITS.html);
|
|
84
|
+
const attrs = c.object(t.attrs, `${path}.attrs`, [], Object.keys(t.attrs ?? {}));
|
|
85
|
+
if (attrs) {
|
|
86
|
+
const names = Object.keys(attrs);
|
|
87
|
+
if (names.length > LIMITS.attrs)
|
|
88
|
+
c.fail(`${path}.attrs`, `must have at most ${LIMITS.attrs} entries`);
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
if (name.length > LIMITS.attrName)
|
|
91
|
+
c.fail(`${path}.attrs`, 'has a name that is too long');
|
|
92
|
+
c.string(attrs[name], `${path}.attrs.${name}`, LIMITS.attrValue);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const rect = c.object(t.rect, `${path}.rect`, ['x', 'y', 'width', 'height']);
|
|
96
|
+
if (rect) {
|
|
97
|
+
c.int(rect.x, `${path}.rect.x`, -COORD, COORD);
|
|
98
|
+
c.int(rect.y, `${path}.rect.y`, -COORD, COORD);
|
|
99
|
+
c.int(rect.width, `${path}.rect.width`, 0, COORD);
|
|
100
|
+
c.int(rect.height, `${path}.rect.height`, 0, COORD);
|
|
101
|
+
}
|
|
102
|
+
if ('source' in t) {
|
|
103
|
+
const source = c.object(t.source, `${path}.source`, ['file', 'line', 'column']);
|
|
104
|
+
if (source) {
|
|
105
|
+
c.string(source.file, `${path}.source.file`, LIMITS.short);
|
|
106
|
+
c.int(source.line, `${path}.source.line`, 0, COORD);
|
|
107
|
+
c.int(source.column, `${path}.source.column`, 0, COORD);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function checkItem(c, value, path, attachmentIds) {
|
|
112
|
+
const type = value?.type;
|
|
113
|
+
if (type !== 'element' && type !== 'screenshot') {
|
|
114
|
+
c.fail(`${path}.type`, 'must be one of element, screenshot');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const item = type === 'element'
|
|
118
|
+
? c.object(value, path, ['id', 'type', 'url', 'comment', 'target'], ['attachment'])
|
|
119
|
+
: c.object(value, path, ['id', 'type', 'url', 'comment', 'attachment']);
|
|
120
|
+
if (!item)
|
|
121
|
+
return;
|
|
122
|
+
c.string(item.id, `${path}.id`, 64, ID);
|
|
123
|
+
c.string(item.url, `${path}.url`, LIMITS.url);
|
|
124
|
+
c.string(item.comment, `${path}.comment`, LIMITS.comment);
|
|
125
|
+
if (type === 'element')
|
|
126
|
+
checkTarget(c, item.target, `${path}.target`);
|
|
127
|
+
if ('attachment' in item && c.string(item.attachment, `${path}.attachment`, 64, ID)) {
|
|
128
|
+
if (!attachmentIds.has(item.attachment))
|
|
129
|
+
c.fail(`${path}.attachment`, 'does not match an attachment');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function checkAttachment(c, value, path) {
|
|
133
|
+
const a = c.object(value, path, ['id', 'mime', 'width', 'height', 'data']);
|
|
134
|
+
if (!a)
|
|
135
|
+
return;
|
|
136
|
+
c.string(a.id, `${path}.id`, 64, ID);
|
|
137
|
+
c.oneOf(a.mime, `${path}.mime`, ['image/png']);
|
|
138
|
+
c.int(a.width, `${path}.width`, 1, LIMITS.attachmentSide);
|
|
139
|
+
c.int(a.height, `${path}.height`, 1, LIMITS.attachmentSide);
|
|
140
|
+
c.string(a.data, `${path}.data`, LIMITS.attachmentChars, BASE64);
|
|
141
|
+
}
|
|
142
|
+
function checkPage(c, value, path) {
|
|
143
|
+
const page = c.object(value, path, ['url', 'title', 'viewport', 'userAgent', 'language']);
|
|
144
|
+
if (!page)
|
|
145
|
+
return;
|
|
146
|
+
c.string(page.url, `${path}.url`, LIMITS.url);
|
|
147
|
+
c.string(page.title, `${path}.title`, LIMITS.short);
|
|
148
|
+
c.string(page.userAgent, `${path}.userAgent`, LIMITS.short);
|
|
149
|
+
c.string(page.language, `${path}.language`, 100);
|
|
150
|
+
const viewport = c.object(page.viewport, `${path}.viewport`, ['width', 'height']);
|
|
151
|
+
if (viewport) {
|
|
152
|
+
c.int(viewport.width, `${path}.viewport.width`, 0, COORD);
|
|
153
|
+
c.int(viewport.height, `${path}.viewport.height`, 0, COORD);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Strict structural check, shared by the widget before sending and by servers on receipt. */
|
|
157
|
+
export function validateEnvelope(data) {
|
|
158
|
+
const c = new Check();
|
|
159
|
+
const e = c.object(data, 'envelope', [
|
|
160
|
+
'v',
|
|
161
|
+
'id',
|
|
162
|
+
'kind',
|
|
163
|
+
'project',
|
|
164
|
+
'createdAt',
|
|
165
|
+
'client',
|
|
166
|
+
'context',
|
|
167
|
+
'comment',
|
|
168
|
+
'items',
|
|
169
|
+
'attachments',
|
|
170
|
+
], ['category', 'fingerprint', 'page']);
|
|
171
|
+
if (!e)
|
|
172
|
+
return { ok: false, errors: c.errors };
|
|
173
|
+
c.oneOf(e.v, 'envelope.v', [ENVELOPE_VERSION]);
|
|
174
|
+
c.string(e.id, 'envelope.id', 64, ID);
|
|
175
|
+
c.oneOf(e.kind, 'envelope.kind', KINDS);
|
|
176
|
+
if ('category' in e)
|
|
177
|
+
c.oneOf(e.category, 'envelope.category', CATEGORIES);
|
|
178
|
+
if ('fingerprint' in e)
|
|
179
|
+
c.string(e.fingerprint, 'envelope.fingerprint', LIMITS.short);
|
|
180
|
+
c.string(e.project, 'envelope.project', 128, PROJECT);
|
|
181
|
+
c.string(e.createdAt, 'envelope.createdAt', 40, TIMESTAMP);
|
|
182
|
+
c.string(e.comment, 'envelope.comment', LIMITS.comment);
|
|
183
|
+
const client = c.object(e.client, 'envelope.client', ['name', 'version']);
|
|
184
|
+
if (client) {
|
|
185
|
+
c.string(client.name, 'envelope.client.name', 100);
|
|
186
|
+
c.string(client.version, 'envelope.client.version', 100);
|
|
187
|
+
}
|
|
188
|
+
if ('page' in e)
|
|
189
|
+
checkPage(c, e.page, 'envelope.page');
|
|
190
|
+
const context = c.object(e.context, 'envelope.context', [], Object.keys(e.context ?? {}));
|
|
191
|
+
if (context) {
|
|
192
|
+
if (Object.keys(context).length > LIMITS.contextKeys)
|
|
193
|
+
c.fail('envelope.context', `must have at most ${LIMITS.contextKeys} keys`);
|
|
194
|
+
let size = Number.POSITIVE_INFINITY;
|
|
195
|
+
try {
|
|
196
|
+
size = JSON.stringify(context).length;
|
|
197
|
+
}
|
|
198
|
+
catch { }
|
|
199
|
+
if (size > LIMITS.contextChars)
|
|
200
|
+
c.fail('envelope.context', `must serialise to at most ${LIMITS.contextChars} characters`);
|
|
201
|
+
}
|
|
202
|
+
const attachments = c.array(e.attachments, 'envelope.attachments', LIMITS.attachments);
|
|
203
|
+
const ids = new Set();
|
|
204
|
+
attachments?.forEach((a, i) => {
|
|
205
|
+
checkAttachment(c, a, `envelope.attachments[${i}]`);
|
|
206
|
+
const id = a?.id;
|
|
207
|
+
if (typeof id === 'string') {
|
|
208
|
+
if (ids.has(id))
|
|
209
|
+
c.fail(`envelope.attachments[${i}].id`, 'is duplicated');
|
|
210
|
+
ids.add(id);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
const items = c.array(e.items, 'envelope.items', LIMITS.items);
|
|
214
|
+
items?.forEach((item, i) => {
|
|
215
|
+
checkItem(c, item, `envelope.items[${i}]`, ids);
|
|
216
|
+
});
|
|
217
|
+
return c.errors.length ? { ok: false, errors: c.errors } : { ok: true, envelope: e };
|
|
218
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Handle, type Options } from './core.ts';
|
|
2
|
+
import Hmm from './Hmm.svelte';
|
|
3
|
+
export type { Handle, Options } from './core.ts';
|
|
4
|
+
export { type Attachment, CATEGORIES, type Category, type ElementItem, ENVELOPE_VERSION, type Envelope, type Item, KINDS, type Kind, LIMITS, type Page, type Rect, type ScreenshotItem, type Target, type Validation, validateEnvelope, } from './envelope.ts';
|
|
5
|
+
export { LABELS, type Labels } from './labels.ts';
|
|
6
|
+
export { redactEnvelope, redactText, redactUrl } from './redact.ts';
|
|
7
|
+
export { httpTransport, SendError, type Transport } from './transport.ts';
|
|
8
|
+
export { VERSION } from './version.ts';
|
|
9
|
+
export { Hmm };
|
|
10
|
+
/** Mounts `Hmm` on the document body, for hosts without a Svelte tree. A second call replaces the first instance. */
|
|
11
|
+
export declare function init(options: Options): Handle;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mount, unmount } from 'svelte';
|
|
2
|
+
import { transportOf } from "./core.js";
|
|
3
|
+
import Hmm from './Hmm.svelte';
|
|
4
|
+
export { CATEGORIES, ENVELOPE_VERSION, KINDS, LIMITS, validateEnvelope, } from "./envelope.js";
|
|
5
|
+
export { LABELS } from "./labels.js";
|
|
6
|
+
export { redactEnvelope, redactText, redactUrl } from "./redact.js";
|
|
7
|
+
export { httpTransport, SendError } from "./transport.js";
|
|
8
|
+
export { VERSION } from "./version.js";
|
|
9
|
+
export { Hmm };
|
|
10
|
+
let current = null;
|
|
11
|
+
/** Mounts `Hmm` on the document body, for hosts without a Svelte tree. A second call replaces the first instance. */
|
|
12
|
+
export function init(options) {
|
|
13
|
+
transportOf(options);
|
|
14
|
+
if (!document.body)
|
|
15
|
+
throw new Error('hmm: call init after the document body exists');
|
|
16
|
+
current?.destroy();
|
|
17
|
+
const component = mount(Hmm, { target: document.body, props: options });
|
|
18
|
+
const handle = {
|
|
19
|
+
open: () => component.show(),
|
|
20
|
+
close: () => component.hide(),
|
|
21
|
+
destroy() {
|
|
22
|
+
void unmount(component);
|
|
23
|
+
if (current === handle)
|
|
24
|
+
current = null;
|
|
25
|
+
},
|
|
26
|
+
get isOpen() {
|
|
27
|
+
return component.isOpen();
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
current = handle;
|
|
31
|
+
return handle;
|
|
32
|
+
}
|
package/dist/inspect.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const SCOPED_CLASS = /^(svelte|s)-[\w-]{5,}$/;
|
|
2
|
+
const HOOK_ATTRS = ['data-testid', 'data-test'];
|
|
3
|
+
const KEPT_ATTRS = ['role', 'aria-label', 'href', 'name', 'type', 'title', 'alt'];
|
|
4
|
+
const SECRET_INPUT = 'input[type="password"]';
|
|
5
|
+
function sourceOf(el) {
|
|
6
|
+
for (let node = el; node; node = node.parentElement) {
|
|
7
|
+
const loc = node.__svelte_meta?.loc;
|
|
8
|
+
if (loc)
|
|
9
|
+
return { file: loc.file.slice(0, 400), line: loc.line, column: loc.column ?? 0 };
|
|
10
|
+
}
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
export function selectorOf(el) {
|
|
14
|
+
const doc = el.ownerDocument;
|
|
15
|
+
const parts = [];
|
|
16
|
+
for (let node = el; node && node.nodeType === 1 && node !== doc.documentElement; node = node.parentElement) {
|
|
17
|
+
for (const attr of HOOK_ATTRS) {
|
|
18
|
+
const value = node.getAttribute(attr);
|
|
19
|
+
if (value) {
|
|
20
|
+
parts.unshift(`[${attr}="${CSS.escape(value)}"]`);
|
|
21
|
+
return parts.join(' > ');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (node.id && !/\d{3,}/.test(node.id)) {
|
|
25
|
+
parts.unshift(`#${CSS.escape(node.id)}`);
|
|
26
|
+
return parts.join(' > ');
|
|
27
|
+
}
|
|
28
|
+
let part = node.localName;
|
|
29
|
+
const classes = [...node.classList].filter((c) => !SCOPED_CLASS.test(c)).slice(0, 2);
|
|
30
|
+
if (classes.length)
|
|
31
|
+
part += classes.map((c) => `.${CSS.escape(c)}`).join('');
|
|
32
|
+
const current = node;
|
|
33
|
+
const siblings = current.parentElement
|
|
34
|
+
? [...current.parentElement.children].filter((s) => s.localName === current.localName)
|
|
35
|
+
: [];
|
|
36
|
+
if (siblings.length > 1)
|
|
37
|
+
part += `:nth-of-type(${siblings.indexOf(current) + 1})`;
|
|
38
|
+
parts.unshift(part);
|
|
39
|
+
if (parts.length >= 6)
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
return parts.join(' > ').slice(0, 800);
|
|
43
|
+
}
|
|
44
|
+
const squash = (text, max) => {
|
|
45
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
46
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
47
|
+
};
|
|
48
|
+
export function describeElement(el) {
|
|
49
|
+
const attrs = {};
|
|
50
|
+
for (const a of [...el.attributes].slice(0, 30)) {
|
|
51
|
+
if ((a.name.startsWith('data-') || KEPT_ATTRS.includes(a.name)) && a.name.length <= 80)
|
|
52
|
+
attrs[a.name] = a.value.slice(0, 200);
|
|
53
|
+
}
|
|
54
|
+
const r = el.getBoundingClientRect();
|
|
55
|
+
const secret = el.matches(SECRET_INPUT) || el.querySelector(SECRET_INPUT) !== null;
|
|
56
|
+
const target = {
|
|
57
|
+
selector: selectorOf(el),
|
|
58
|
+
tag: el.localName.slice(0, 80),
|
|
59
|
+
// biome-ignore lint/performance/useDomNodeTextContent: innerText leaves out hidden text
|
|
60
|
+
text: squash(el.innerText ?? el.textContent ?? '', 200),
|
|
61
|
+
html: secret ? '' : squash(el.outerHTML, 1000),
|
|
62
|
+
attrs,
|
|
63
|
+
rect: {
|
|
64
|
+
x: Math.round(r.x),
|
|
65
|
+
y: Math.round(r.y),
|
|
66
|
+
width: Math.round(r.width),
|
|
67
|
+
height: Math.round(r.height),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const source = sourceOf(el);
|
|
71
|
+
if (source)
|
|
72
|
+
target.source = source;
|
|
73
|
+
return target;
|
|
74
|
+
}
|
|
75
|
+
export const labelOf = (target) => target.text ? `<${target.tag}> ${target.text.slice(0, 40)}` : `<${target.tag}>`;
|
package/dist/labels.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const LABELS: {
|
|
2
|
+
launcher: string;
|
|
3
|
+
title: string;
|
|
4
|
+
close: string;
|
|
5
|
+
idea: string;
|
|
6
|
+
problem: string;
|
|
7
|
+
question: string;
|
|
8
|
+
other: string;
|
|
9
|
+
commentPlaceholder: string;
|
|
10
|
+
pick: string;
|
|
11
|
+
shot: string;
|
|
12
|
+
pickHint: string;
|
|
13
|
+
capturing: string;
|
|
14
|
+
itemPlaceholder: string;
|
|
15
|
+
screenshot: string;
|
|
16
|
+
remove: string;
|
|
17
|
+
removeImage: string;
|
|
18
|
+
discard: string;
|
|
19
|
+
review: string;
|
|
20
|
+
back: string;
|
|
21
|
+
send: string;
|
|
22
|
+
reviewIntro: string;
|
|
23
|
+
category: string;
|
|
24
|
+
page: string;
|
|
25
|
+
comment: string;
|
|
26
|
+
context: string;
|
|
27
|
+
raw: string;
|
|
28
|
+
sent: string;
|
|
29
|
+
queued: string;
|
|
30
|
+
failed: string;
|
|
31
|
+
blocked: string;
|
|
32
|
+
shotFailed: string;
|
|
33
|
+
full: string;
|
|
34
|
+
done: string;
|
|
35
|
+
};
|
|
36
|
+
export type Labels = typeof LABELS;
|
package/dist/labels.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const LABELS = {
|
|
2
|
+
launcher: 'Feedback',
|
|
3
|
+
title: 'Send feedback',
|
|
4
|
+
close: 'Close',
|
|
5
|
+
idea: 'Idea',
|
|
6
|
+
problem: 'Problem',
|
|
7
|
+
question: 'Question',
|
|
8
|
+
other: 'Other',
|
|
9
|
+
commentPlaceholder: 'What did you notice, or what would you like to see?',
|
|
10
|
+
pick: 'Select something on the page',
|
|
11
|
+
shot: 'Add screenshot',
|
|
12
|
+
pickHint: 'Click what you mean. Press Esc to cancel.',
|
|
13
|
+
capturing: 'Capturing…',
|
|
14
|
+
itemPlaceholder: 'What about this?',
|
|
15
|
+
screenshot: 'Screenshot',
|
|
16
|
+
remove: 'Remove',
|
|
17
|
+
removeImage: 'Remove image',
|
|
18
|
+
discard: 'Discard',
|
|
19
|
+
review: 'Review',
|
|
20
|
+
back: 'Back',
|
|
21
|
+
send: 'Send',
|
|
22
|
+
reviewIntro: 'This is everything that will be sent.',
|
|
23
|
+
category: 'Type',
|
|
24
|
+
page: 'Page',
|
|
25
|
+
comment: 'Comment',
|
|
26
|
+
context: 'Details',
|
|
27
|
+
raw: 'Raw data',
|
|
28
|
+
sent: 'Thanks, your feedback was sent.',
|
|
29
|
+
queued: 'The connection failed. Your feedback is saved and will be sent automatically.',
|
|
30
|
+
failed: 'Sending failed. Please try again.',
|
|
31
|
+
blocked: 'This feedback cannot be sent from this page.',
|
|
32
|
+
shotFailed: 'The screenshot could not be taken.',
|
|
33
|
+
full: 'You have reached the maximum for one report.',
|
|
34
|
+
done: 'Done',
|
|
35
|
+
};
|
package/dist/picker.d.ts
ADDED
package/dist/picker.js
ADDED
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Envelope } from './envelope.ts';
|
|
2
|
+
export declare function redactText(text: string): string;
|
|
3
|
+
export declare function redactUrl(url: string): string;
|
|
4
|
+
/** Scrubs every string except attachment image data; images cannot be scrubbed. */
|
|
5
|
+
export declare function redactEnvelope(envelope: Envelope): Envelope;
|