snagset 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eurico Sá Fernandes
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 ADDED
@@ -0,0 +1,143 @@
1
+ # `snagset`
2
+
3
+ Click-to-comment review for a live website. Your client clicks the thing that's
4
+ wrong and types; you get the pin, the screenshot, the console and the route.
5
+
6
+ One import, or one script tag. Both do exactly the same thing.
7
+
8
+ ```bash
9
+ npm install snagset
10
+ ```
11
+
12
+ ## Vanilla
13
+
14
+ ```js
15
+ import snagset from "snagset";
16
+
17
+ snagset.init({ host: "https://review.example.com", site: "prj_…" });
18
+ ```
19
+
20
+ That is the whole API. `snagset.destroy()` takes it back off; `snagset.tag({…})`
21
+ gives you the tag as data if you would rather render it yourself.
22
+
23
+ ## React
24
+
25
+ ```jsx
26
+ import Snagset from "snagset/react";
27
+
28
+ <Snagset host="https://review.example.com" site="prj_…" />
29
+ ```
30
+
31
+ ## Vue
32
+
33
+ ```vue
34
+ <script setup>
35
+ import Snagset from "snagset/vue";
36
+ </script>
37
+
38
+ <template>
39
+ <Snagset host="https://review.example.com" site="prj_…" />
40
+ </template>
41
+ ```
42
+
43
+ ## Next.js
44
+
45
+ ```tsx
46
+ // app/layout.tsx
47
+ import { Snagset } from "snagset/next";
48
+
49
+ <body>
50
+ {children}
51
+ <Snagset host="https://review.example.com" site="prj_…" />
52
+ </body>
53
+ ```
54
+
55
+ No `"use client"` — it renders the script tag on the server, so it costs zero
56
+ client JavaScript. Leave the props off and it reads `NEXT_PUBLIC_SNAGSET_HOST`
57
+ and `NEXT_PUBLIC_SNAGSET_SITE`, so each environment configures itself from
58
+ `.env`. Works in the Pages Router too.
59
+
60
+ ## Vite — and Nuxt, SvelteKit, SolidStart, anything on Vite
61
+
62
+ ```ts
63
+ // vite.config.ts
64
+ import { snagset } from "snagset/vite";
65
+
66
+ export default defineConfig({
67
+ plugins: [snagset({ host: "https://review.example.com", site: "prj_…" })],
68
+ });
69
+ ```
70
+
71
+ One line in a file the project already has; the tag is injected before
72
+ `</body>`. In Nuxt, put it under `vite: { plugins: […] }` in `nuxt.config`.
73
+ Want it only in dev? Use Vite's own idiom:
74
+ `mode === "development" ? snagset({…}) : undefined`. Shipping the tag to
75
+ production is safe either way — who sees the toolbar is a dashboard setting,
76
+ checked by the tag on load, not something baked into your build.
77
+
78
+ ## Astro, Rails — anything that renders a layout
79
+
80
+ ```astro
81
+ ---
82
+ import { snagsetTag } from "snagset/astro";
83
+ const tag = snagsetTag({ host: "https://review.example.com", site: "prj_…" });
84
+ ---
85
+ <script src={tag.src} {...tag.attrs} defer is:inline></script>
86
+ ```
87
+
88
+ `is:inline` matters — without it Astro bundles the script and rewrites the
89
+ `src`, and the widget finds its chunks relative to its own `src`.
90
+
91
+ ## Or no npm at all
92
+
93
+ ```html
94
+ <script src="https://review.example.com/snag.js" data-snag-site="prj_…" defer></script>
95
+ ```
96
+
97
+ That is what this package writes for you. There is no feature behind the npm
98
+ install — if a script tag suits your stack better, use it. It works on
99
+ WordPress, Webflow, Shopify and Rails, where a React-only tool cannot go.
100
+
101
+ ## Options
102
+
103
+ Every `data-snag-*` attribute, as a camelCase key. `host` and `site` are the
104
+ only required ones.
105
+
106
+ | Option | What it does |
107
+ |---|---|
108
+ | `host` | Your Snagset instance |
109
+ | `site` | The project uid, from the dashboard |
110
+ | `env` | Which environment this build is — `staging`, `production` |
111
+ | `theme` | `light`, `dark`, or leave it to follow the page |
112
+ | `lang` | UI language |
113
+ | `position` | Where the toolbar sits |
114
+ | `hotkey` | Default is `alt+c` |
115
+ | `branch`, `build` | Stamped onto every comment, for "which deploy was this?" |
116
+
117
+ ## What this package is
118
+
119
+ **A loader.** It writes the script tag and nothing else — the widget itself is
120
+ served by your Snagset instance, at your instance's version.
121
+
122
+ That is deliberate. The widget is a lazy chunk graph: about 10 kB loads on
123
+ every page and the remaining ~245 kB arrives only if somebody opens the toolbar.
124
+ Bundling it here would put all of it into your bundle, and pin a widget version
125
+ in your `package.json` that can drift from the server it talks to.
126
+
127
+ It also means the bytes come from your own origin, so your CSP is one
128
+ `script-src 'self'` entry and there is no third party to justify to anyone.
129
+
130
+ ## Requirements
131
+
132
+ A Snagset instance. It self-hosts on Node and Postgres —
133
+ [snagset/snagset](https://github.com/snagset/snagset), or
134
+ `npx create-snagset` to scaffold one.
135
+
136
+ ---
137
+
138
+ **Early release.** This is a `0.0.x` line: the tag it writes is stable and
139
+ tested, but expect the odd rough edge, and pin the version if you need
140
+ stability. Issues and notes are welcome at
141
+ [snagset/snagset](https://github.com/snagset/snagset).
142
+
143
+ MIT.
@@ -0,0 +1,48 @@
1
+ import { tagFor, type InitOptions } from "./index.ts";
2
+ /**
3
+ * `snagset/astro` — D63.
4
+ *
5
+ * Astro is the case where the OTHER answer is better, and this file says so
6
+ * rather than pretending otherwise.
7
+ *
8
+ * Astro renders on the server and ships zero JavaScript by default. A component
9
+ * that exists only to run a `init()` effect in the browser would need a client
10
+ * directive (`client:load`), which means shipping a hydration runtime to boot a
11
+ * script tag — paying for a framework to do something HTML does natively.
12
+ *
13
+ * So this subpath gives you the tag as data, to render in your layout:
14
+ *
15
+ * ```astro
16
+ * ---
17
+ * import { snagsetTag } from "snagset/astro";
18
+ * const tag = snagsetTag({ host: "https://review.example.com", site: "prj_…" });
19
+ * ---
20
+ * <script src={tag.src} {...tag.attrs} defer is:inline></script>
21
+ * ```
22
+ *
23
+ * `is:inline` matters: without it Astro takes ownership of the script, bundles
24
+ * it, and rewrites the `src` — which breaks `resolveAssetBase`, because the
25
+ * widget finds its sibling chunks relative to its own `<script src>`.
26
+ *
27
+ * The same function is what a plain Nuxt/SvelteKit/Rails layout wants, and
28
+ * `useHead`/`svelte:head` consume it directly.
29
+ */
30
+ export declare function snagsetTag(options: InitOptions): {
31
+ src: string;
32
+ attrs: Record<string, string>;
33
+ /** The whole thing as a string, for a template that wants one. */
34
+ html: string;
35
+ };
36
+ export { tagFor };
37
+ /**
38
+ * Default-exported too, so a consumer who prefers defaults has one — but the
39
+ * NAMED import is what the docs show.
40
+ *
41
+ * `import snagset from "snagset/astro"` would give you a callable `snagset(…)`
42
+ * beside the core's `snagset.init(…)`, which is two different shapes under one
43
+ * name. `snagsetTag` says what it returns.
44
+ *
45
+ * The same function is on the core as `snagset.tag(…)` — it touches no DOM, so
46
+ * it is safe to call while rendering on a server.
47
+ */
48
+ export default snagsetTag;
package/dist/astro.js ADDED
@@ -0,0 +1,50 @@
1
+ import { tagFor } from "./index.js";
2
+ /**
3
+ * `snagset/astro` — D63.
4
+ *
5
+ * Astro is the case where the OTHER answer is better, and this file says so
6
+ * rather than pretending otherwise.
7
+ *
8
+ * Astro renders on the server and ships zero JavaScript by default. A component
9
+ * that exists only to run a `init()` effect in the browser would need a client
10
+ * directive (`client:load`), which means shipping a hydration runtime to boot a
11
+ * script tag — paying for a framework to do something HTML does natively.
12
+ *
13
+ * So this subpath gives you the tag as data, to render in your layout:
14
+ *
15
+ * ```astro
16
+ * ---
17
+ * import { snagsetTag } from "snagset/astro";
18
+ * const tag = snagsetTag({ host: "https://review.example.com", site: "prj_…" });
19
+ * ---
20
+ * <script src={tag.src} {...tag.attrs} defer is:inline></script>
21
+ * ```
22
+ *
23
+ * `is:inline` matters: without it Astro takes ownership of the script, bundles
24
+ * it, and rewrites the `src` — which breaks `resolveAssetBase`, because the
25
+ * widget finds its sibling chunks relative to its own `<script src>`.
26
+ *
27
+ * The same function is what a plain Nuxt/SvelteKit/Rails layout wants, and
28
+ * `useHead`/`svelte:head` consume it directly.
29
+ */
30
+ export function snagsetTag(options) {
31
+ const { src, attrs } = tagFor(options);
32
+ const rendered = Object.entries(attrs)
33
+ .map(([k, v]) => (v === "" ? ` ${k}` : ` ${k}="${v.replace(/"/g, "&quot;")}"`))
34
+ .join("");
35
+ return { src, attrs, html: `<script src="${src}"${rendered} defer></script>` };
36
+ }
37
+ export { tagFor };
38
+ /**
39
+ * Default-exported too, so a consumer who prefers defaults has one — but the
40
+ * NAMED import is what the docs show.
41
+ *
42
+ * `import snagset from "snagset/astro"` would give you a callable `snagset(…)`
43
+ * beside the core's `snagset.init(…)`, which is two different shapes under one
44
+ * name. `snagsetTag` says what it returns.
45
+ *
46
+ * The same function is on the core as `snagset.tag(…)` — it touches no DOM, so
47
+ * it is safe to call while rendering on a server.
48
+ */
49
+ export default snagsetTag;
50
+ //# sourceMappingURL=astro.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"astro.js","sourceRoot":"","sources":["../src/astro.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAoB,MAAM,YAAY,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,UAAU,CAAC,OAAoB;IAM7C,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;IACvC,MAAM,QAAQ,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;SACnC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC;SAC9E,IAAI,CAAC,EAAE,CAAC,CAAC;IACZ,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,gBAAgB,GAAG,IAAI,QAAQ,kBAAkB,EAAE,CAAC;AACjF,CAAC;AAED,OAAO,EAAE,MAAM,EAAE,CAAC;AAElB;;;;;;;;;;GAUG;AACH,eAAe,UAAU,CAAC"}
@@ -0,0 +1,148 @@
1
+ import { type ScriptAttr } from "./vendor/types/names";
2
+ /**
3
+ * `snagset` — the npm way in. D63, and D37's structure with D63's name.
4
+ *
5
+ * ── THIS PACKAGE DOES NOT CONTAIN THE WIDGET, AND THAT IS THE DESIGN ──
6
+ *
7
+ * It writes the script tag for you. Nothing more. The question that deserves an
8
+ * answer before anyone reads further is *why not just bundle it*, and the
9
+ * answer is measured rather than argued:
10
+ *
11
+ * | file | bytes |
12
+ * |---|---|
13
+ * | `snag.js` — the eager stub | **10 230** |
14
+ * | the other nine chunks, lazy | 245 945 |
15
+ *
16
+ * The widget is a lazy chunk graph. Ten kilobytes load on every page; the
17
+ * screenshot code, the panels, the fuzzy anchorer and the rest arrive only if
18
+ * somebody actually opens the toolbar. Bundling that into `snagset` would put a
19
+ * quarter of a megabyte into the host application's own bundle — or force them
20
+ * to teach their bundler about our code-splitting — and foundations §6's eager
21
+ * envelope, the budget the whole architecture is built around, would be a
22
+ * number about a file nobody loads.
23
+ *
24
+ * Three more things fall out of the shim that a bundle would break:
25
+ *
26
+ * · **No version skew.** The chunks are fetched from the server at the
27
+ * server's version. A bundled widget pinned in someone's `package.json` at
28
+ * an older version would request chunk names that build no longer has.
29
+ * · **Same-origin stays same-origin** (D38, A7). The bytes come from the
30
+ * instance the widget already talks to, so a client's CSP is one
31
+ * `script-src 'self'` entry and there is no third party to justify.
32
+ * · **The script tag is still sacred** (D19, LOCK L5) — literally, not by
33
+ * analogy. This package emits exactly the tag the docs tell you to paste,
34
+ * so a bug here cannot produce behaviour the tag cannot.
35
+ *
36
+ * ── AND NO CUSTOM ELEMENT ──
37
+ *
38
+ * D37 sketched registering one so that "Vue, Svelte and Astro need no adapter
39
+ * at all". That was reasoning about a *bundled* widget, where a framework needs
40
+ * something to render. With a shim there is nothing to render — `init()` is
41
+ * already framework-agnostic, and the framework subpaths are lifecycle glue
42
+ * over this file rather than wrappers around an element. The custom element
43
+ * would be bytes added to the eager stub to solve a problem this shape does not
44
+ * have.
45
+ */
46
+ /**
47
+ * Everything the script tag accepts, as an object.
48
+ *
49
+ * Keys are the attribute names minus their `data-snag-` prefix, which is the
50
+ * mapping `scriptAttr()` already owns — so this type cannot drift from the
51
+ * attribute registry, and a new attribute is accepted here the day it is
52
+ * registered rather than the day somebody remembers this file.
53
+ */
54
+ export type SnagsetConfig = Partial<Record<ScriptAttr, string | number | boolean>>;
55
+ export interface InitOptions extends SnagsetConfig {
56
+ /**
57
+ * The instance. `https://review.example.com`.
58
+ *
59
+ * Required, and the ONE thing with no sensible default: the widget has to
60
+ * know which server to talk to, and guessing `location.origin` would be
61
+ * wrong on every site that is not itself the instance — which is all of them.
62
+ */
63
+ host: string;
64
+ /** The project uid — `prj_…`, from the dashboard's Sites screen. */
65
+ site: string;
66
+ /** Where to put the tag. Defaults to `document.head`. */
67
+ target?: Element;
68
+ }
69
+ /** The tag this package writes, exposed for tests and for docs to render. */
70
+ export declare function tagFor(options: InitOptions): {
71
+ src: string;
72
+ attrs: Record<string, string>;
73
+ };
74
+ /**
75
+ * Start Snagset on this page.
76
+ *
77
+ * ```js
78
+ * import snagset from "snagset";
79
+ *
80
+ * snagset.init({ host: "https://review.example.com", site: "prj_…" });
81
+ * ```
82
+ *
83
+ * ── WHY `init` AND NOT `mount` ──
84
+ *
85
+ * Because the product already had a name for this and it was not mine to
86
+ * choose. [02 §5](../../../specs/02-widget-runtime.md) defines
87
+ * **`Snagset.init(config)`** as the public JS API — *"may be called repeatedly
88
+ * before activation; each call merges"* — and `window.Snagset.init` /
89
+ * `window.Snagset.destroy` are on the live object right now.
90
+ *
91
+ * The first version of this package called it `mount`/`unmount`, which is React
92
+ * vocabulary imported into a package whose underlying object has said `init`
93
+ * since 02 draft 1. A developer reading `snagset.mount()` in their app and
94
+ * `Snagset.init()` in the console would reasonably conclude they were two
95
+ * different things. Raised by Eurico — *"init seems better than mount"* — and
96
+ * he is right for a better reason than either of us gave at the time.
97
+ *
98
+ * ── REPEATED CALLS MERGE, LIKE THE SPEC SAYS ──
99
+ *
100
+ * A second `init()` does not add a second tag (that would boot a second
101
+ * widget). It forwards the new config to `Snagset.init()`, which deep-merges
102
+ * it — so this behaves the way §5 documents rather than being a same-named
103
+ * function with different rules. When the widget has not finished loading the
104
+ * config is QUEUED and flushed on the script's `load` event, because a caller
105
+ * has no way to know whether an async script has arrived yet and "your second
106
+ * call was silently dropped depending on timing" is not a contract.
107
+ *
108
+ * Returns a function that removes the tag.
109
+ */
110
+ declare function init(options: InitOptions): () => void;
111
+ /**
112
+ * Take Snagset off the page — [02 §5](../../../specs/02-widget-runtime.md)'s
113
+ * `Snagset.destroy()`.
114
+ *
115
+ * Delegates to the widget's OWN teardown rather than removing the script
116
+ * element, because removing a script element does nothing at all: the code has
117
+ * already run, the listeners are already bound, and the toolbar is already in
118
+ * the DOM. That is the difference between a teardown that works and one that
119
+ * appears to.
120
+ */
121
+ declare function destroy(): void;
122
+ /**
123
+ * Is the tag on this page?
124
+ *
125
+ * `isInstalled`, not `isActive`: 02 §5's `state` is `inert | dormant | active`
126
+ * and "active" already means *the toolbar is open*, which is a different
127
+ * question with a different answer. Read `window.Snagset.state` for that one.
128
+ */
129
+ declare function isInstalled(): boolean;
130
+ /**
131
+ * The default export — the package's own name, carrying 02 §5's verbs.
132
+ *
133
+ * An OBJECT with `init`, not a callable. The first version made `snagset({…})`
134
+ * itself the call, on the argument that one verb should be the noun; with the
135
+ * verb named `init` that argument evaporates, and `snagset({…})` alongside
136
+ * `snagset.init({…})` would be two ways to do one thing in a brand-new API.
137
+ * This is the shape every SDK a developer has installed before this one has —
138
+ * `posthog.init`, `Sentry.init`, `mixpanel.init` — and now also the shape the
139
+ * widget's own global has, which matters more.
140
+ */
141
+ export declare const snagset: {
142
+ init: typeof init;
143
+ destroy: typeof destroy;
144
+ isInstalled: typeof isInstalled;
145
+ tag: typeof tagFor;
146
+ };
147
+ export { init, destroy, isInstalled };
148
+ export default snagset;
package/dist/index.js ADDED
@@ -0,0 +1,152 @@
1
+ import { GLOBALS, scriptAttr } from "./vendor/types/names.js";
2
+ const INSTALLED = "__snagset_npm_installed__";
3
+ /** The tag this package writes, exposed for tests and for docs to render. */
4
+ export function tagFor(options) {
5
+ const host = String(options.host).replace(/\/+$/, "");
6
+ const attrs = {};
7
+ for (const [key, value] of Object.entries(options)) {
8
+ if (key === "target" || key === "host" || value === undefined || value === null)
9
+ continue;
10
+ // `false` is dropped rather than written as the string "false". Every one
11
+ // of these attributes is read by `loader.ts` as a STRING, and the presence
12
+ // of `data-snag-disabled="false"` reads as "disabled" to anything doing a
13
+ // has-attribute check — which is the shape of kill switch §6 uses.
14
+ if (value === false)
15
+ continue;
16
+ attrs[scriptAttr(key)] = value === true ? "" : String(value);
17
+ }
18
+ // `host` is both the config and the source, exactly as a hand-pasted tag has
19
+ // it: the tag's own `src` is what `resolveAssetBase` falls back to, so the
20
+ // chunks resolve with no extra configuration.
21
+ attrs[scriptAttr("host")] = host;
22
+ return { src: `${host}/snag.js`, attrs };
23
+ }
24
+ /**
25
+ * Start Snagset on this page.
26
+ *
27
+ * ```js
28
+ * import snagset from "snagset";
29
+ *
30
+ * snagset.init({ host: "https://review.example.com", site: "prj_…" });
31
+ * ```
32
+ *
33
+ * ── WHY `init` AND NOT `mount` ──
34
+ *
35
+ * Because the product already had a name for this and it was not mine to
36
+ * choose. [02 §5](../../../specs/02-widget-runtime.md) defines
37
+ * **`Snagset.init(config)`** as the public JS API — *"may be called repeatedly
38
+ * before activation; each call merges"* — and `window.Snagset.init` /
39
+ * `window.Snagset.destroy` are on the live object right now.
40
+ *
41
+ * The first version of this package called it `mount`/`unmount`, which is React
42
+ * vocabulary imported into a package whose underlying object has said `init`
43
+ * since 02 draft 1. A developer reading `snagset.mount()` in their app and
44
+ * `Snagset.init()` in the console would reasonably conclude they were two
45
+ * different things. Raised by Eurico — *"init seems better than mount"* — and
46
+ * he is right for a better reason than either of us gave at the time.
47
+ *
48
+ * ── REPEATED CALLS MERGE, LIKE THE SPEC SAYS ──
49
+ *
50
+ * A second `init()` does not add a second tag (that would boot a second
51
+ * widget). It forwards the new config to `Snagset.init()`, which deep-merges
52
+ * it — so this behaves the way §5 documents rather than being a same-named
53
+ * function with different rules. When the widget has not finished loading the
54
+ * config is QUEUED and flushed on the script's `load` event, because a caller
55
+ * has no way to know whether an async script has arrived yet and "your second
56
+ * call was silently dropped depending on timing" is not a contract.
57
+ *
58
+ * Returns a function that removes the tag.
59
+ */
60
+ function init(options) {
61
+ // SSR guard first. This package is imported at module scope in a Next.js or
62
+ // Nuxt component, so it IS evaluated on the server, and touching `document`
63
+ // there throws before anything can explain itself.
64
+ if (typeof document === "undefined")
65
+ return () => { };
66
+ const w = window;
67
+ if (w[INSTALLED]) {
68
+ merge(options);
69
+ return () => { };
70
+ }
71
+ if (!options.host)
72
+ throw new Error("snagset: `host` is required — the URL of your Snagset instance.");
73
+ if (!options.site)
74
+ throw new Error("snagset: `site` is required — the project uid, which looks like prj_….");
75
+ const { src, attrs } = tagFor(options);
76
+ const el = document.createElement("script");
77
+ el.src = src;
78
+ el.defer = true;
79
+ for (const [name, value] of Object.entries(attrs))
80
+ el.setAttribute(name, value);
81
+ el.addEventListener("load", flush, { once: true });
82
+ w[INSTALLED] = true;
83
+ (options.target ?? document.head).appendChild(el);
84
+ return () => {
85
+ el.remove();
86
+ w[INSTALLED] = false;
87
+ queued.length = 0;
88
+ };
89
+ }
90
+ /** Config handed to `init()` after the first call, before the widget exists. */
91
+ const queued = [];
92
+ function widget() {
93
+ if (typeof window === "undefined")
94
+ return undefined;
95
+ return window[GLOBALS.api];
96
+ }
97
+ function merge(options) {
98
+ const api = widget();
99
+ // `target` is OURS — where to put the tag — and means nothing to the widget.
100
+ const { target: _drop, ...config } = options;
101
+ if (api?.init)
102
+ api.init(config);
103
+ else
104
+ queued.push(options);
105
+ }
106
+ function flush() {
107
+ const pending = queued.splice(0);
108
+ for (const options of pending)
109
+ merge(options);
110
+ }
111
+ /**
112
+ * Take Snagset off the page — [02 §5](../../../specs/02-widget-runtime.md)'s
113
+ * `Snagset.destroy()`.
114
+ *
115
+ * Delegates to the widget's OWN teardown rather than removing the script
116
+ * element, because removing a script element does nothing at all: the code has
117
+ * already run, the listeners are already bound, and the toolbar is already in
118
+ * the DOM. That is the difference between a teardown that works and one that
119
+ * appears to.
120
+ */
121
+ function destroy() {
122
+ if (typeof window === "undefined")
123
+ return;
124
+ widget()?.destroy?.();
125
+ window[INSTALLED] = false;
126
+ queued.length = 0;
127
+ }
128
+ /**
129
+ * Is the tag on this page?
130
+ *
131
+ * `isInstalled`, not `isActive`: 02 §5's `state` is `inert | dormant | active`
132
+ * and "active" already means *the toolbar is open*, which is a different
133
+ * question with a different answer. Read `window.Snagset.state` for that one.
134
+ */
135
+ function isInstalled() {
136
+ return typeof window !== "undefined" && !!window[INSTALLED];
137
+ }
138
+ /**
139
+ * The default export — the package's own name, carrying 02 §5's verbs.
140
+ *
141
+ * An OBJECT with `init`, not a callable. The first version made `snagset({…})`
142
+ * itself the call, on the argument that one verb should be the noun; with the
143
+ * verb named `init` that argument evaporates, and `snagset({…})` alongside
144
+ * `snagset.init({…})` would be two ways to do one thing in a brand-new API.
145
+ * This is the shape every SDK a developer has installed before this one has —
146
+ * `posthog.init`, `Sentry.init`, `mixpanel.init` — and now also the shape the
147
+ * widget's own global has, which matters more.
148
+ */
149
+ export const snagset = { init, destroy, isInstalled, tag: tagFor };
150
+ export { init, destroy, isInstalled };
151
+ export default snagset;
152
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,UAAU,EAAmB,MAAM,sBAAsB,CAAC;AAwE5E,MAAM,SAAS,GAAG,2BAA2B,CAAC;AAE9C,6EAA6E;AAC7E,MAAM,UAAU,MAAM,CAAC,OAAoB;IACzC,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACtD,MAAM,KAAK,GAA2B,EAAE,CAAC;IACzC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACnD,IAAI,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,MAAM,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;YAAE,SAAS;QAC1F,0EAA0E;QAC1E,2EAA2E;QAC3E,0EAA0E;QAC1E,mEAAmE;QACnE,IAAI,KAAK,KAAK,KAAK;YAAE,SAAS;QAC9B,KAAK,CAAC,UAAU,CAAC,GAAiB,CAAC,CAAC,GAAG,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC7E,CAAC;IACD,6EAA6E;IAC7E,2EAA2E;IAC3E,8CAA8C;IAC9C,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,GAAG,IAAI,CAAC;IACjC,OAAO,EAAE,GAAG,EAAE,GAAG,IAAI,UAAU,EAAE,KAAK,EAAE,CAAC;AAC3C,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,SAAS,IAAI,CAAC,OAAoB;IAChC,4EAA4E;IAC5E,4EAA4E;IAC5E,mDAAmD;IACnD,IAAI,OAAO,QAAQ,KAAK,WAAW;QAAE,OAAO,GAAG,EAAE,GAAE,CAAC,CAAC;IAErD,MAAM,CAAC,GAAG,MAA4C,CAAC;IAEvD,IAAI,CAAC,CAAC,SAAS,CAAC,EAAE,CAAC;QACjB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,OAAO,GAAG,EAAE,GAAE,CAAC,CAAC;IAClB,CAAC;IAED,IAAI,CAAC,OAAO,CAAC,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,iEAAiE,CAAC,CAAC;IACtG,IAAI,CAAC,OAAO,CAAC,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,wEAAwE,CAAC,CAAC;IAE7G,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;IACvC,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;IAC5C,EAAE,CAAC,GAAG,GAAG,GAAG,CAAC;IACb,EAAE,CAAC,KAAK,GAAG,IAAI,CAAC;IAChB,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAChF,EAAE,CAAC,gBAAgB,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IAEnD,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC;IACpB,CAAC,OAAO,CAAC,MAAM,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;IAElD,OAAO,GAAG,EAAE;QACV,EAAE,CAAC,MAAM,EAAE,CAAC;QACZ,CAAC,CAAC,SAAS,CAAC,GAAG,KAAK,CAAC;QACrB,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;IACpB,CAAC,CAAC;AACJ,CAAC;AAED,gFAAgF;AAChF,MAAM,MAAM,GAAkB,EAAE,CAAC;AAEjC,SAAS,MAAM;IACb,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAO,SAAS,CAAC;IACpD,OAAQ,MAA6C,CAAC,OAAO,CAAC,GAAG,CAAU,CAAC;AAC9E,CAAC;AAED,SAAS,KAAK,CAAC,OAAoB;IACjC,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC;IACrB,6EAA6E;IAC7E,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,EAAE,GAAG,OAAO,CAAC;IAC7C,IAAI,GAAG,EAAE,IAAI;QAAE,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;;QAC3B,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AAC5B,CAAC;AAED,SAAS,KAAK;IACZ,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IACjC,KAAK,MAAM,OAAO,IAAI,OAAO;QAAE,KAAK,CAAC,OAAO,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,OAAO;IACd,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAO;IAC1C,MAAM,EAAE,EAAE,OAAO,EAAE,EAAE,CAAC;IACrB,MAA6C,CAAC,SAAS,CAAC,GAAG,KAAK,CAAC;IAClE,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;AACpB,CAAC;AAED;;;;;;GAMG;AACH,SAAS,WAAW;IAClB,OAAO,OAAO,MAAM,KAAK,WAAW,IAAI,CAAC,CAAE,MAA6C,CAAC,SAAS,CAAC,CAAC;AACtG,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,CAAC;AAEnE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC;AACtC,eAAe,OAAO,CAAC"}
package/dist/next.d.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { createElement } from "react";
2
+ import { type InitOptions } from "./index.ts";
3
+ /**
4
+ * `snagset/next` — D67.
5
+ *
6
+ * ```tsx
7
+ * // app/layout.tsx
8
+ * import { Snagset } from "snagset/next";
9
+ *
10
+ * <body>
11
+ * {children}
12
+ * <Snagset host="https://review.example.com" site="prj_…" />
13
+ * </body>
14
+ * ```
15
+ *
16
+ * ── A SERVER COMPONENT, NOT A CLIENT EFFECT ──
17
+ *
18
+ * `snagset/react` runs `init()` in a `useEffect`, because a generic React tree
19
+ * offers nothing better. Next offers something better: this component has no
20
+ * hooks and no `"use client"`, so in the App Router it renders on the server
21
+ * and the script tag arrives IN the HTML — zero client JavaScript, zero
22
+ * hydration, byte-for-byte the tag the docs tell you to paste. It degrades
23
+ * cleanly too: in the Pages Router (or any client tree) it renders the same
24
+ * element and React mounts it, which boots the script just the same.
25
+ *
26
+ * ── WHY THERE IS NO `withSnagset(nextConfig)` WRAPPER ──
27
+ *
28
+ * Vercel's own toolbar ships one because their setup needs `next.config`
29
+ * rewrites to proxy toolbar traffic through the deployment. Ours needs no
30
+ * proxy: the widget is served by the studio's instance and talks back to that
31
+ * same origin (D38). A config wrapper would exist to look like theirs, not to
32
+ * do anything — the component is the whole install.
33
+ *
34
+ * ── PROPS, OR `NEXT_PUBLIC_SNAGSET_*` ──
35
+ *
36
+ * A bare `<Snagset />` reads `NEXT_PUBLIC_SNAGSET_HOST` and
37
+ * `NEXT_PUBLIC_SNAGSET_SITE`, so per-environment values live where a Next
38
+ * project already keeps them (`.env.local`, the host's env UI) instead of in
39
+ * the layout. The names carry Next's own public prefix — deliberately NOT bare
40
+ * `SNAGSET_*`, which is the server's reserved namespace (08 §4): an instance
41
+ * colocated with the app must never find widget config in variables it treats
42
+ * as its own and refuses to boot on.
43
+ *
44
+ * Missing config renders NOTHING rather than throwing: this sits in the root
45
+ * layout, and a review widget that can take down every page of the site over
46
+ * an unset env var would violate the product's oldest rule. The dev-only
47
+ * console line is the diagnostic.
48
+ */
49
+ export type SnagsetProps = Partial<InitOptions>;
50
+ export declare function Snagset(props?: SnagsetProps): ReturnType<typeof createElement> | null;
51
+ export default Snagset;
package/dist/next.js ADDED
@@ -0,0 +1,17 @@
1
+ import { createElement } from "react";
2
+ import { tagFor } from "./index.js";
3
+ export function Snagset(props = {}) {
4
+ const hasEnv = typeof process !== "undefined" && !!process.env;
5
+ const host = props.host ?? (hasEnv ? process.env.NEXT_PUBLIC_SNAGSET_HOST : undefined);
6
+ const site = props.site ?? (hasEnv ? process.env.NEXT_PUBLIC_SNAGSET_SITE : undefined);
7
+ if (!host || !site) {
8
+ if (hasEnv && process.env.NODE_ENV !== "production") {
9
+ console.warn("snagset/next: rendering nothing — set `host` and `site` props, or NEXT_PUBLIC_SNAGSET_HOST and NEXT_PUBLIC_SNAGSET_SITE.");
10
+ }
11
+ return null;
12
+ }
13
+ const { src, attrs } = tagFor({ ...props, host, site });
14
+ return createElement("script", { src, defer: true, ...attrs });
15
+ }
16
+ export default Snagset;
17
+ //# sourceMappingURL=next.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"next.js","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AACtC,OAAO,EAAE,MAAM,EAAoB,MAAM,YAAY,CAAC;AAkEtD,MAAM,UAAU,OAAO,CAAC,QAAsB,EAAE;IAC9C,MAAM,MAAM,GAAG,OAAO,OAAO,KAAK,WAAW,IAAI,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC;IAC/D,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,OAAQ,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACxF,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,OAAQ,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACxF,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;QACnB,IAAI,MAAM,IAAI,OAAQ,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;YACrD,OAAO,CAAC,IAAI,CACV,0HAA0H,CAC3H,CAAC;QACJ,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,MAAM,CAAC,EAAE,GAAG,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IACxD,OAAO,aAAa,CAAC,QAAQ,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,KAAK,EAAE,CAAC,CAAC;AACjE,CAAC;AAED,eAAe,OAAO,CAAC"}