cmskite 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CMSKite
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,462 @@
1
+ # cmskite
2
+
3
+ The official CMSKite client. Fetch your posts and measure who reads them.
4
+
5
+ ```bash
6
+ npm install cmskite
7
+ ```
8
+
9
+ ```ts
10
+ import { createCMSKite } from 'cmskite'
11
+
12
+ const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })
13
+
14
+ const { items } = await cms.posts.list({ limit: 10 })
15
+ ```
16
+
17
+ That is the whole of getting started. Everything below is detail you can reach
18
+ for when you need it.
19
+
20
+ ---
21
+
22
+ ## Contents
23
+
24
+ - [Initialization](#initialization)
25
+ - [Fetching posts](#fetching-posts)
26
+ - [Pagination](#pagination)
27
+ - [Filtering and sorting](#filtering-and-sorting)
28
+ - [Categories, tags and authors](#categories-tags-and-authors)
29
+ - [Search](#search)
30
+ - [Error handling](#error-handling)
31
+ - [Analytics](#analytics)
32
+ - [Next.js](#nextjs)
33
+ - [React](#react)
34
+ - [Node](#node)
35
+ - [Security: which key goes where](#security-which-key-goes-where)
36
+ - [Versioning and compatibility](#versioning-and-compatibility)
37
+
38
+ ---
39
+
40
+ ## Initialization
41
+
42
+ ```ts
43
+ import { createCMSKite } from 'cmskite'
44
+
45
+ const cms = createCMSKite({
46
+ apiKey: process.env.CMSKITE_API_KEY!,
47
+
48
+ // Everything below is optional.
49
+ baseUrl: 'https://api.cmskite.com', // only for a self-hosted deployment
50
+ timeoutMs: 10_000, // per request
51
+ headers: { 'x-trace-id': traceId }, // sent on every request
52
+ fetch: myFetch, // a test double, or fetch with retries
53
+ })
54
+ ```
55
+
56
+ Create it once and reuse it. It holds no connection and no mutable state, so
57
+ module scope is the right place for it.
58
+
59
+ Your API key is on **Project → API keys** in the dashboard. A CMSKite key is
60
+ read-only and belongs to exactly one project; see
61
+ [Security](#security-which-key-goes-where) before you put one in a browser
62
+ bundle.
63
+
64
+ ---
65
+
66
+ ## Fetching posts
67
+
68
+ ```ts
69
+ // A page of posts.
70
+ const { items, pagination } = await cms.posts.list({ limit: 10 })
71
+
72
+ // One post by id.
73
+ const post = await cms.posts.get('post_01a0c...')
74
+
75
+ // One post by its address — what a /blog/[slug] page wants.
76
+ const post = await cms.posts.getBySlug('building-better-apps')
77
+
78
+ // The same, but null instead of a throw when there is no such post.
79
+ const post = await cms.posts.findBySlug(slug)
80
+ if (!post) return notFound()
81
+
82
+ // Posts like this one.
83
+ const { items } = await cms.posts.related(post.id, { limit: 3 })
84
+ ```
85
+
86
+ A list omits post bodies — twenty full articles is a payload nobody asked for —
87
+ and says so with `bodyOmitted: true`. Fetching one post always includes it.
88
+
89
+ ### Old addresses keep working
90
+
91
+ If a post has been renamed, its old slug still resolves and the answer carries
92
+ `slugRedirectedFrom`. Use it to redirect rather than serving one post at two
93
+ addresses:
94
+
95
+ ```ts
96
+ const post = await cms.posts.getBySlug(params.slug)
97
+ if (post.slugRedirectedFrom) redirect(`/blog/${post.slug}`)
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Pagination
103
+
104
+ CMSKite paginates by cursor, not by page number — `?page=5000` on a large table
105
+ is a slow query anybody can issue. The cursor is opaque; pass it back and never
106
+ parse it.
107
+
108
+ ```ts
109
+ let cursor: string | undefined
110
+ do {
111
+ const page = await cms.posts.list({ limit: 50, cursor })
112
+ render(page.items)
113
+ cursor = page.pagination.nextCursor ?? undefined
114
+ } while (cursor)
115
+ ```
116
+
117
+ Or let the SDK write that loop for you. This is an async iterator, so a site
118
+ with four thousand posts never holds four thousand posts in memory:
119
+
120
+ ```ts
121
+ for await (const post of cms.posts.all({ status: 'published' })) {
122
+ sitemap.add(`/blog/${post.slug}`)
123
+ }
124
+ ```
125
+
126
+ `pagination.total` is only present when you ask for it, because counting is not
127
+ free:
128
+
129
+ ```ts
130
+ const page = await cms.posts.list({ withTotal: true })
131
+ page.pagination.total // number
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Filtering and sorting
137
+
138
+ ```ts
139
+ await cms.posts.list({
140
+ limit: 20,
141
+ status: 'published',
142
+ category: 'engineering', // slug or id
143
+ tag: 'databases', // slug or id
144
+ author: 'author_01a0c...',
145
+ search: 'postgres',
146
+ sort: '-publishedAt', // '-' is descending
147
+ publishedAfter: '2026-01-01T00:00:00Z',
148
+ })
149
+ ```
150
+
151
+ Omitted filters are left out of the request entirely, so you can pass an
152
+ optional value straight through without guarding it first.
153
+
154
+ ---
155
+
156
+ ## Categories, tags and authors
157
+
158
+ ```ts
159
+ const { items: categories } = await cms.categories.list()
160
+ const { items: tags } = await cms.tags.list()
161
+ const { items: authors } = await cms.authors.list()
162
+
163
+ const category = await cms.categories.get('cat_01a0c...')
164
+ ```
165
+
166
+ A category carries its full `path` — `engineering/databases` — so two
167
+ subcategories both called "Guides" can be told apart.
168
+
169
+ ---
170
+
171
+ ## Search
172
+
173
+ ```ts
174
+ const { items } = await cms.posts.search('connection pooling', { limit: 10 })
175
+ ```
176
+
177
+ Searches titles and bodies. Search is a paid capability; on a plan without it
178
+ the call throws a `CMSKiteError` with `status: 403`.
179
+
180
+ ---
181
+
182
+ ## Error handling
183
+
184
+ Everything that can go wrong arrives as one type, so you write one `catch`.
185
+
186
+ ```ts
187
+ import { CMSKiteError } from 'cmskite'
188
+
189
+ try {
190
+ const post = await cms.posts.getBySlug(slug)
191
+ } catch (error) {
192
+ if (error instanceof CMSKiteError) {
193
+ error.status // 404, 401, 0 when nothing came back at all
194
+ error.code // 'NOT_FOUND', 'RATE_LIMITED', 'NETWORK', …
195
+ error.requestId // quote this when you ask us about it
196
+ error.isNotFound // convenience
197
+ error.isAuthError
198
+ error.isRetryable
199
+ }
200
+ throw error
201
+ }
202
+ ```
203
+
204
+ A dropped connection, a timeout and a proxy answering with HTML are all
205
+ `CMSKiteError` too — you will never see a bare `TypeError: Failed to fetch`
206
+ from this package.
207
+
208
+ **Retries.** The client retries once, and only what a retry can fix: a dropped
209
+ connection, a timeout, a 429, a 5xx. A 404 or a 401 is a settled answer, and
210
+ asking twice only delays showing somebody the truth.
211
+
212
+ **Cancellation.** Every method takes a signal:
213
+
214
+ ```ts
215
+ const controller = new AbortController()
216
+ const page = await cms.posts.list({ search }, { signal: controller.signal })
217
+ controller.abort() // e.g. a newer search superseded this one
218
+ ```
219
+
220
+ ---
221
+
222
+ ## Analytics
223
+
224
+ CMSKite counts a view when a page reports one — not when the API is asked for a
225
+ post. Those are different numbers: a framework fetches every post at build
226
+ time, a cached page is fetched once and read ten thousand times, and a crawler
227
+ fetches each one exactly once.
228
+
229
+ So tracking lives in the browser:
230
+
231
+ ```ts
232
+ import { createTracker } from 'cmskite/browser'
233
+
234
+ const analytics = createTracker({ apiKey: PUBLIC_KEY })
235
+
236
+ analytics.trackView(post.id)
237
+ analytics.trackClick(post.id, 'https://example.com/pricing')
238
+ ```
239
+
240
+ **It cannot break your site.** Nothing here throws, nothing awaits a response,
241
+ and a failed request is swallowed. Your article renders whether or not our
242
+ analytics is having a good day.
243
+
244
+ **It cannot slow your site.** Events are queued and sent in one batch on a
245
+ timer, using `sendBeacon` where it exists — which hands the batch to the
246
+ browser and returns immediately, and still delivers after the page is closed.
247
+
248
+ **It will not double-count.** A view is remembered for the tab, so a refresh, a
249
+ re-render or a client-side navigation back to a post already read reports
250
+ nothing. The server deduplicates again by a deterministic id, because storage
251
+ can be cleared and a second tab has its own.
252
+
253
+ Clicks are not deduplicated: pressing the same link twice is two clicks.
254
+
255
+ Turn it off where you want to:
256
+
257
+ ```ts
258
+ createTracker({ apiKey: PUBLIC_KEY, enabled: process.env.NODE_ENV === 'production' })
259
+ ```
260
+
261
+ Supply your own `fetch` to see the requests in a test:
262
+
263
+ ```ts
264
+ const tracker = createTracker({ apiKey: KEY, fetch: myFetch })
265
+ ```
266
+
267
+ ### A view is a browser, not a script
268
+
269
+ Requests that announce themselves as automated are accepted and not counted —
270
+ `curl`, a crawler, a link-preview fetcher, a headless browser. That includes
271
+ Node's own `fetch`, whose user agent is `node`, so calling `trackView` from a
272
+ server records nothing. That is deliberate: a server fetching a post is not a
273
+ reader, which is the whole reason it is not counted as one.
274
+
275
+ ### What is collected
276
+
277
+ No cookie, no browser storage identifier, no IP address and no user agent are
278
+ stored. A visitor is a salted hash that cannot be recomputed once the day's
279
+ salt rotates at midnight — including by us. The path is stored without its
280
+ query string, and a referrer is reduced to its host.
281
+
282
+ There is no identifier, so there is nothing for a visitor to consent to.
283
+
284
+ ---
285
+
286
+ ## Next.js
287
+
288
+ Fetch on the server, track in the browser. That split is the whole integration.
289
+
290
+ **`app/blog/[slug]/page.tsx`**
291
+
292
+ ```tsx
293
+ import { notFound } from 'next/navigation'
294
+ import { createCMSKite } from 'cmskite'
295
+ import { TrackView } from './track-view'
296
+
297
+ const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })
298
+
299
+ export async function generateStaticParams() {
300
+ const params = []
301
+ for await (const post of cms.posts.all({ status: 'published' })) {
302
+ params.push({ slug: post.slug })
303
+ }
304
+ return params
305
+ }
306
+
307
+ export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
308
+ const { slug } = await params
309
+ const post = await cms.posts.findBySlug(slug)
310
+ if (!post) notFound()
311
+
312
+ return (
313
+ <article>
314
+ <h1>{post.title}</h1>
315
+ <div dangerouslySetInnerHTML={{ __html: post.body }} />
316
+ <TrackView postId={post.id} />
317
+ </article>
318
+ )
319
+ }
320
+ ```
321
+
322
+ **`app/blog/[slug]/track-view.tsx`**
323
+
324
+ ```tsx
325
+ 'use client'
326
+
327
+ import { useTrackView } from 'cmskite/react'
328
+
329
+ export function TrackView({ postId }: { postId: string }) {
330
+ useTrackView(postId, { apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })
331
+ return null
332
+ }
333
+ ```
334
+
335
+ Two keys, two `.env` entries, and they are not the same key — see
336
+ [Security](#security-which-key-goes-where).
337
+
338
+ ```
339
+ CMSKITE_API_KEY=csk_live_… # server only
340
+ NEXT_PUBLIC_CMSKITE_KEY=csk_live_… # in the bundle, deliberately
341
+ ```
342
+
343
+ **Revalidation.** The SDK uses `fetch`, so Next's cache options work. Pass them
344
+ through your own wrapper if you want ISR:
345
+
346
+ ```ts
347
+ const cms = createCMSKite({
348
+ apiKey: process.env.CMSKITE_API_KEY!,
349
+ fetch: (url, init) => fetch(url, { ...init, next: { revalidate: 300 } }),
350
+ })
351
+ ```
352
+
353
+ ---
354
+
355
+ ## React
356
+
357
+ For a client-side app, fetch with whatever you already use for server state —
358
+ TanStack Query, SWR, your own hook. The SDK deliberately ships no data hook: a
359
+ `usePosts` here would be a worse query library wearing our name.
360
+
361
+ What it does ship is view tracking, because getting that right means knowing
362
+ when a component mounted, surviving React's double-invoked effects in
363
+ development, and not re-reporting on every render.
364
+
365
+ ```tsx
366
+ 'use client'
367
+
368
+ import { useCMSKiteAnalytics, useTrackView } from 'cmskite/react'
369
+
370
+ export function Article({ post }) {
371
+ useTrackView(post.id, { apiKey: PUBLIC_KEY })
372
+ const analytics = useCMSKiteAnalytics({ apiKey: PUBLIC_KEY })
373
+
374
+ return (
375
+ <article>
376
+ <h1>{post.title}</h1>
377
+ <a href={cta} onClick={() => analytics.trackClick(post.id, cta)}>Read more</a>
378
+ </article>
379
+ )
380
+ }
381
+ ```
382
+
383
+ Passing a new `postId` reports the new post, which is what makes client-side
384
+ navigation between two articles count as two views with no router integration.
385
+
386
+ ---
387
+
388
+ ## Node
389
+
390
+ ```ts
391
+ import { createCMSKite } from 'cmskite'
392
+
393
+ const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })
394
+
395
+ const { items } = await cms.posts.list({ limit: 100 })
396
+ ```
397
+
398
+ Node 18 or newer, for built-in `fetch` and `AbortSignal.any`. ESM and CommonJS
399
+ both work:
400
+
401
+ ```js
402
+ const { createCMSKite } = require('cmskite')
403
+ ```
404
+
405
+ ---
406
+
407
+ ## Security: which key goes where
408
+
409
+ CMSKite has one kind of key and it is **read-only**. The only scope a key can
410
+ hold is `blog:read`: it returns published content and can write, change or
411
+ delete nothing. There is no secret credential in this SDK, because there is
412
+ none to have.
413
+
414
+ That makes a key safe in a browser bundle, with one thing to do first:
415
+
416
+ **Restrict the origins.** Project → Settings → Allowed origins. List the sites
417
+ that may use the key. Until you do, any page anywhere can read your published
418
+ content with it — which is content you are already publishing, but it is still
419
+ your bandwidth.
420
+
421
+ **Use two keys anyway.** One for your server, one for the browser. Not because
422
+ the browser one is weaker, but because you can revoke the exposed one without
423
+ taking your build down.
424
+
425
+ | | Server key | Browser key |
426
+ |---|---|---|
427
+ | Where | `CMSKITE_API_KEY` | `NEXT_PUBLIC_CMSKITE_KEY` |
428
+ | Used by | `createCMSKite` | `createTracker` |
429
+ | Origin allowlist | not needed | **set it** |
430
+
431
+ Never put a dashboard session token or an agent token in a browser. Those act
432
+ for a person; an API key acts for a project.
433
+
434
+ ---
435
+
436
+ ## Versioning and compatibility
437
+
438
+ The package follows semantic versioning, and the guarantee is about what you
439
+ import rather than about what we run.
440
+
441
+ - **Patch** — fixes. Nothing you wrote changes.
442
+ - **Minor** — new methods, new optional fields on a response. Existing code
443
+ keeps working, and a field you already read keeps meaning what it meant.
444
+ - **Major** — something you import changed shape. Rare, and it will come with a
445
+ migration note.
446
+
447
+ The API is versioned separately at `/v1`. The SDK pins to it, so an API change
448
+ reaches you as an SDK release you choose to take rather than as your site
449
+ breaking on a Tuesday.
450
+
451
+ Anything not exported from `cmskite`, `cmskite/browser` or
452
+ `cmskite/react` is internal, including the transport and the response
453
+ envelope. Those are the parts we expect to change.
454
+
455
+ Response types describe the API, not the database. A column renamed behind the
456
+ scenes is not your problem.
457
+
458
+ ---
459
+
460
+ ## License
461
+
462
+ MIT
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Reporting what a reader did, without ever being able to break the page.
3
+ *
4
+ * Three properties, and every decision below follows from them:
5
+ *
6
+ * It cannot throw. Not on a network failure, not on a 500, not if the key is
7
+ * wrong. A customer's article must render whether or not our analytics is
8
+ * having a good day, so every path here ends in a swallowed error.
9
+ *
10
+ * It cannot block. Events are queued and flushed on a timer, and the flush
11
+ * uses `sendBeacon` where it exists -- which hands the batch to the browser
12
+ * and returns immediately, and which still delivers after the page has been
13
+ * closed. Nothing awaits a response, because there is nothing in the
14
+ * response.
15
+ *
16
+ * It cannot double-count. A view is remembered in `sessionStorage`, so a
17
+ * refresh, a re-render and a client-side navigation back to a post already
18
+ * read report nothing at all. The server deduplicates again by a
19
+ * deterministic id, because storage can be cleared and a second tab has its
20
+ * own.
21
+ */
22
+ interface TrackerOptions {
23
+ apiKey: string;
24
+ baseUrl?: string;
25
+ /**
26
+ * Off switch. `false` queues nothing and sends nothing.
27
+ *
28
+ * For a development build, or for a site that asks first and only turns this
29
+ * on afterwards.
30
+ */
31
+ enabled?: boolean;
32
+ /** How long to hold events before sending. Default 1000ms. */
33
+ flushIntervalMs?: number;
34
+ /**
35
+ * Supply your own, for a test.
36
+ *
37
+ * The main client has taken one since it was written, and this did not --
38
+ * which made the tracker the one part of the SDK that could not be exercised
39
+ * outside a browser. It was verified by asserting that it did not throw,
40
+ * which is a test that passes while nothing at all is recorded, and that is
41
+ * exactly what happened the first time it was run end to end.
42
+ */
43
+ fetch?: typeof globalThis.fetch;
44
+ }
45
+ type EventType = 'view' | 'click';
46
+ declare class CMSKiteAnalytics {
47
+ private readonly endpoint;
48
+ private readonly apiKey;
49
+ private readonly enabled;
50
+ private readonly flushIntervalMs;
51
+ private readonly fetchImpl;
52
+ private queue;
53
+ private timer;
54
+ private listening;
55
+ constructor(options: TrackerOptions);
56
+ /**
57
+ * One reader seeing one post.
58
+ *
59
+ * Call it when the post is rendered. Calling it again for the same post in
60
+ * the same tab does nothing, which is what makes it safe to put in a React
61
+ * effect that runs on every render.
62
+ */
63
+ trackView(postId: string, path?: string): void;
64
+ /**
65
+ * A link press.
66
+ *
67
+ * Not deduplicated: pressing the same link twice is two clicks, and the nonce
68
+ * is what tells the server so.
69
+ */
70
+ trackClick(postId: string, target?: string, path?: string): void;
71
+ /** Sends whatever is queued now. Called for you on page hide. */
72
+ flush(): void;
73
+ /** Stops the timer and the listeners. For a test, or a single-page teardown. */
74
+ destroy(): void;
75
+ private push;
76
+ private send;
77
+ /**
78
+ * What this tab has already reported.
79
+ *
80
+ * `sessionStorage`, not `localStorage`: a view should be counted again
81
+ * tomorrow, and a session is the unit the server deduplicates on too. A
82
+ * browser that refuses storage -- private mode, blocked site data -- falls
83
+ * back to counting the view, which is the right way to be wrong.
84
+ */
85
+ private alreadySeen;
86
+ private remember;
87
+ private listenForUnload;
88
+ private clearTimer;
89
+ }
90
+
91
+ export { CMSKiteAnalytics as C, type EventType as E, type TrackerOptions as T };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Reporting what a reader did, without ever being able to break the page.
3
+ *
4
+ * Three properties, and every decision below follows from them:
5
+ *
6
+ * It cannot throw. Not on a network failure, not on a 500, not if the key is
7
+ * wrong. A customer's article must render whether or not our analytics is
8
+ * having a good day, so every path here ends in a swallowed error.
9
+ *
10
+ * It cannot block. Events are queued and flushed on a timer, and the flush
11
+ * uses `sendBeacon` where it exists -- which hands the batch to the browser
12
+ * and returns immediately, and which still delivers after the page has been
13
+ * closed. Nothing awaits a response, because there is nothing in the
14
+ * response.
15
+ *
16
+ * It cannot double-count. A view is remembered in `sessionStorage`, so a
17
+ * refresh, a re-render and a client-side navigation back to a post already
18
+ * read report nothing at all. The server deduplicates again by a
19
+ * deterministic id, because storage can be cleared and a second tab has its
20
+ * own.
21
+ */
22
+ interface TrackerOptions {
23
+ apiKey: string;
24
+ baseUrl?: string;
25
+ /**
26
+ * Off switch. `false` queues nothing and sends nothing.
27
+ *
28
+ * For a development build, or for a site that asks first and only turns this
29
+ * on afterwards.
30
+ */
31
+ enabled?: boolean;
32
+ /** How long to hold events before sending. Default 1000ms. */
33
+ flushIntervalMs?: number;
34
+ /**
35
+ * Supply your own, for a test.
36
+ *
37
+ * The main client has taken one since it was written, and this did not --
38
+ * which made the tracker the one part of the SDK that could not be exercised
39
+ * outside a browser. It was verified by asserting that it did not throw,
40
+ * which is a test that passes while nothing at all is recorded, and that is
41
+ * exactly what happened the first time it was run end to end.
42
+ */
43
+ fetch?: typeof globalThis.fetch;
44
+ }
45
+ type EventType = 'view' | 'click';
46
+ declare class CMSKiteAnalytics {
47
+ private readonly endpoint;
48
+ private readonly apiKey;
49
+ private readonly enabled;
50
+ private readonly flushIntervalMs;
51
+ private readonly fetchImpl;
52
+ private queue;
53
+ private timer;
54
+ private listening;
55
+ constructor(options: TrackerOptions);
56
+ /**
57
+ * One reader seeing one post.
58
+ *
59
+ * Call it when the post is rendered. Calling it again for the same post in
60
+ * the same tab does nothing, which is what makes it safe to put in a React
61
+ * effect that runs on every render.
62
+ */
63
+ trackView(postId: string, path?: string): void;
64
+ /**
65
+ * A link press.
66
+ *
67
+ * Not deduplicated: pressing the same link twice is two clicks, and the nonce
68
+ * is what tells the server so.
69
+ */
70
+ trackClick(postId: string, target?: string, path?: string): void;
71
+ /** Sends whatever is queued now. Called for you on page hide. */
72
+ flush(): void;
73
+ /** Stops the timer and the listeners. For a test, or a single-page teardown. */
74
+ destroy(): void;
75
+ private push;
76
+ private send;
77
+ /**
78
+ * What this tab has already reported.
79
+ *
80
+ * `sessionStorage`, not `localStorage`: a view should be counted again
81
+ * tomorrow, and a session is the unit the server deduplicates on too. A
82
+ * browser that refuses storage -- private mode, blocked site data -- falls
83
+ * back to counting the view, which is the right way to be wrong.
84
+ */
85
+ private alreadySeen;
86
+ private remember;
87
+ private listenForUnload;
88
+ private clearTimer;
89
+ }
90
+
91
+ export { CMSKiteAnalytics as C, type EventType as E, type TrackerOptions as T };