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 +21 -0
- package/README.md +462 -0
- package/dist/analytics-B0zzt0nW.d.cts +91 -0
- package/dist/analytics-B0zzt0nW.d.ts +91 -0
- package/dist/browser.cjs +152 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +30 -0
- package/dist/browser.d.ts +30 -0
- package/dist/browser.js +12 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-DT3V5CH2.js +145 -0
- package/dist/chunk-DT3V5CH2.js.map +1 -0
- package/dist/chunk-QXWHDCMJ.js +143 -0
- package/dist/chunk-QXWHDCMJ.js.map +1 -0
- package/dist/index.cjs +348 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +278 -0
- package/dist/index.d.ts +278 -0
- package/dist/index.js +204 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +175 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +45 -0
- package/dist/react.d.ts +45 -0
- package/dist/react.js +32 -0
- package/dist/react.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/transport.ts","../src/analytics.ts","../src/react.ts"],"names":["useRef","useEffect","useMemo"],"mappings":"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","file":"react.cjs","sourcesContent":["import { CMSKiteError, ErrorCode } from './errors.js'\nimport type { Page, RequestOptions } from './types.js'\n\nexport const DEFAULT_BASE_URL = 'https://api.cmskite.com'\n/**\n * What a caller may pass as a query.\n *\n * An interface with named optional fields -- which every query type here is --\n * does not satisfy `Record<string, unknown>`, because TypeScript will not give\n * one an implicit index signature. Widening to `object` and reading entries off\n * it is the honest way to accept both, and the values are stringified anyway.\n */\ntype QueryInput = Record<string, unknown> | object\n\n/** Sent so we can tell an SDK request from a hand-rolled one in support. */\nconst VERSION = '0.1.0'\n\nexport interface TransportConfig {\n apiKey: string\n baseUrl: string\n timeoutMs: number\n /** Extra headers on every request. For a proxy or a trace id. */\n headers: Record<string, string>\n fetch: typeof globalThis.fetch\n}\n\ninterface Envelope<T> {\n success?: boolean\n data?: T\n pagination?: Page<T>['pagination']\n error?: { code?: string; message?: string; details?: Record<string, unknown> }\n requestId?: string\n}\n\n/**\n * One request, and everything that can go wrong with it turned into one type.\n *\n * Retries exactly once, and only what a retry can fix: a dropped connection, a\n * timeout, a 429, a 5xx. A 404 is a settled answer and asking again only delays\n * showing somebody the truth. A 401 will not become a 200 by being repeated.\n */\nexport async function request<T>(\n config: TransportConfig,\n method: string,\n path: string,\n init: { query?: QueryInput; body?: unknown; options?: RequestOptions } = {},\n): Promise<{ data: T; pagination?: Page<T>['pagination'] }> {\n const url = buildUrl(config.baseUrl, path, init.query)\n const timeoutMs = init.options?.timeoutMs ?? config.timeoutMs\n\n let lastError: CMSKiteError | null = null\n for (let attempt = 0; attempt < 2; attempt++) {\n try {\n return await once<T>(config, method, url, timeoutMs, init)\n } catch (error) {\n const failure = error instanceof CMSKiteError ? error : toError(error)\n // A caller who aborted wants the abort, not a retry and not our wrapper.\n if (init.options?.signal?.aborted) throw failure\n if (!failure.isRetryable || attempt === 1) throw failure\n lastError = failure\n // One short pause. Anything longer is a decision the caller should make.\n await new Promise((resolve) => setTimeout(resolve, 250))\n }\n }\n\n throw lastError ?? new CMSKiteError(0, ErrorCode.NETWORK, 'Request failed.')\n}\n\nasync function once<T>(\n config: TransportConfig,\n method: string,\n url: string,\n timeoutMs: number,\n init: { body?: unknown; options?: RequestOptions },\n): Promise<{ data: T; pagination?: Page<T>['pagination'] }> {\n /**\n * The caller's signal and our timeout, combined.\n *\n * `AbortSignal.any` is the correct tool and exists everywhere this package\n * supports; the timeout controller is still created so that a timeout can be\n * told apart from a caller's abort in the catch below.\n */\n const timeout = new AbortController()\n const timer = setTimeout(() => timeout.abort(), timeoutMs)\n const signal = init.options?.signal\n ? AbortSignal.any([init.options.signal, timeout.signal])\n : timeout.signal\n\n let response: Response\n let text: string\n try {\n response = await config.fetch(url, {\n method,\n signal,\n headers: {\n authorization: `Bearer ${config.apiKey}`,\n accept: 'application/json',\n 'x-cmskite-sdk': VERSION,\n ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),\n ...config.headers,\n },\n ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),\n })\n text = await response.text()\n } catch (error) {\n throw toError(error, timeout.signal.aborted, timeoutMs)\n } finally {\n clearTimeout(timer)\n }\n\n let payload: Envelope<T>\n try {\n payload = text ? (JSON.parse(text) as Envelope<T>) : {}\n } catch {\n throw new CMSKiteError(\n response.status,\n ErrorCode.BAD_RESPONSE,\n 'CMSKite returned something this client could not read.',\n {},\n response.headers.get('x-request-id'),\n )\n }\n\n if (!response.ok || payload.success === false) {\n throw new CMSKiteError(\n response.status,\n payload.error?.code ?? codeForStatus(response.status),\n payload.error?.message ?? `CMSKite answered ${response.status}.`,\n payload.error?.details ?? {},\n payload.requestId ?? response.headers.get('x-request-id'),\n )\n }\n\n const result: { data: T; pagination?: Page<T>['pagination'] } = { data: payload.data as T }\n if (payload.pagination) result.pagination = payload.pagination\n return result\n}\n\nfunction toError(error: unknown, timedOut = false, timeoutMs = 0): CMSKiteError {\n if (error instanceof CMSKiteError) return error\n if (timedOut) {\n return new CMSKiteError(0, ErrorCode.TIMEOUT, `CMSKite did not answer within ${timeoutMs}ms.`)\n }\n if (error instanceof Error && error.name === 'AbortError') {\n return new CMSKiteError(0, ErrorCode.NETWORK, 'The request was cancelled.')\n }\n return new CMSKiteError(0, ErrorCode.NETWORK, 'Could not reach CMSKite. Check the connection.')\n}\n\nfunction codeForStatus(status: number): string {\n if (status === 401) return ErrorCode.UNAUTHENTICATED\n if (status === 403) return ErrorCode.FORBIDDEN\n if (status === 404) return ErrorCode.NOT_FOUND\n if (status === 429) return ErrorCode.RATE_LIMITED\n if (status >= 400 && status < 500) return ErrorCode.INVALID_REQUEST\n return 'INTERNAL_ERROR'\n}\n\n/**\n * Query building, in one place.\n *\n * `undefined`, `null` and the empty string are dropped rather than sent, so a\n * caller can pass an optional filter straight through without writing the same\n * three-line guard at every call site.\n */\nexport function buildUrl(baseUrl: string, path: string, query: QueryInput = {}): string {\n const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)\n for (const [key, value] of Object.entries(query as Record<string, unknown>)) {\n if (value === undefined || value === null || value === '') continue\n url.searchParams.set(key, String(value))\n }\n return url.toString()\n}\n","import { DEFAULT_BASE_URL } from './transport.js'\n\n/**\n * Reporting what a reader did, without ever being able to break the page.\n *\n * Three properties, and every decision below follows from them:\n *\n * It cannot throw. Not on a network failure, not on a 500, not if the key is\n * wrong. A customer's article must render whether or not our analytics is\n * having a good day, so every path here ends in a swallowed error.\n *\n * It cannot block. Events are queued and flushed on a timer, and the flush\n * uses `sendBeacon` where it exists -- which hands the batch to the browser\n * and returns immediately, and which still delivers after the page has been\n * closed. Nothing awaits a response, because there is nothing in the\n * response.\n *\n * It cannot double-count. A view is remembered in `sessionStorage`, so a\n * refresh, a re-render and a client-side navigation back to a post already\n * read report nothing at all. The server deduplicates again by a\n * deterministic id, because storage can be cleared and a second tab has its\n * own.\n */\n\nexport interface TrackerOptions {\n apiKey: string\n baseUrl?: string\n /**\n * Off switch. `false` queues nothing and sends nothing.\n *\n * For a development build, or for a site that asks first and only turns this\n * on afterwards.\n */\n enabled?: boolean\n /** How long to hold events before sending. Default 1000ms. */\n flushIntervalMs?: number\n /**\n * Supply your own, for a test.\n *\n * The main client has taken one since it was written, and this did not --\n * which made the tracker the one part of the SDK that could not be exercised\n * outside a browser. It was verified by asserting that it did not throw,\n * which is a test that passes while nothing at all is recorded, and that is\n * exactly what happened the first time it was run end to end.\n */\n fetch?: typeof globalThis.fetch\n}\n\nexport type EventType = 'view' | 'click'\n\ninterface QueuedEvent {\n type: EventType\n postId: string\n path?: string\n target?: string\n nonce?: string\n}\n\nconst MAX_BATCH = 50\nconst STORAGE_PREFIX = 'cmskite:v:'\n\nexport class CMSKiteAnalytics {\n private readonly endpoint: string\n private readonly apiKey: string\n private readonly enabled: boolean\n private readonly flushIntervalMs: number\n private readonly fetchImpl: typeof globalThis.fetch | undefined\n private queue: QueuedEvent[] = []\n private timer: ReturnType<typeof setTimeout> | null = null\n private listening = false\n\n constructor(options: TrackerOptions) {\n this.apiKey = options.apiKey\n this.endpoint = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, '')}/v1/blog/events`\n this.enabled = options.enabled !== false\n this.flushIntervalMs = options.flushIntervalMs ?? 1000\n this.fetchImpl = options.fetch\n this.listenForUnload()\n }\n\n /**\n * One reader seeing one post.\n *\n * Call it when the post is rendered. Calling it again for the same post in\n * the same tab does nothing, which is what makes it safe to put in a React\n * effect that runs on every render.\n */\n trackView(postId: string, path?: string): void {\n if (!this.enabled || !postId) return\n if (this.alreadySeen(postId)) return\n this.remember(postId)\n this.push({ type: 'view', postId, ...(path ? { path } : { path: currentPath() }) })\n }\n\n /**\n * A link press.\n *\n * Not deduplicated: pressing the same link twice is two clicks, and the nonce\n * is what tells the server so.\n */\n trackClick(postId: string, target?: string, path?: string): void {\n if (!this.enabled || !postId) return\n this.push({\n type: 'click',\n postId,\n ...(target ? { target: target.slice(0, 300) } : {}),\n ...(path ? { path } : { path: currentPath() }),\n nonce: nonce(),\n })\n }\n\n /** Sends whatever is queued now. Called for you on page hide. */\n flush(): void {\n if (this.queue.length === 0) return\n const batch = this.queue.splice(0, MAX_BATCH)\n this.clearTimer()\n this.send(batch)\n }\n\n /** Stops the timer and the listeners. For a test, or a single-page teardown. */\n destroy(): void {\n this.flush()\n this.clearTimer()\n }\n\n // --- internals ------------------------------------------------------------\n\n private push(event: QueuedEvent): void {\n this.queue.push(event)\n if (this.queue.length >= MAX_BATCH) return this.flush()\n if (!this.timer) {\n this.timer = setTimeout(() => this.flush(), this.flushIntervalMs)\n // Never hold a Node process open for a view count.\n ;(this.timer as unknown as { unref?: () => void }).unref?.()\n }\n }\n\n private send(events: QueuedEvent[]): void {\n const body = JSON.stringify({ events })\n\n /**\n * `sendBeacon` first, because it is the one mechanism the browser promises\n * to finish after the page is gone -- which is exactly when the last view\n * of a session is reported.\n *\n * It cannot carry an Authorization header, so the key rides in the URL.\n * That is safe here and nowhere else: the key is read-only, scoped to one\n * project, and already present in the page that fetched the content.\n */\n // An injected fetch means a test, and a test wants to see the request\n // rather than hand it to a beacon it cannot observe.\n if (!this.fetchImpl) {\n try {\n if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {\n const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`\n const blob = new Blob([body], { type: 'application/json' })\n if (navigator.sendBeacon(url, blob)) return\n }\n } catch {\n // Fall through to fetch. Nothing here is worth surfacing.\n }\n }\n\n try {\n void (this.fetchImpl ?? fetch)(this.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json', authorization: `Bearer ${this.apiKey}` },\n body,\n // Survives the navigation that triggered it, like sendBeacon does.\n keepalive: true,\n }).catch(() => {\n // Analytics must never break the page it is measuring.\n })\n } catch {\n // Nor must it break when `fetch` itself is missing.\n }\n }\n\n /**\n * What this tab has already reported.\n *\n * `sessionStorage`, not `localStorage`: a view should be counted again\n * tomorrow, and a session is the unit the server deduplicates on too. A\n * browser that refuses storage -- private mode, blocked site data -- falls\n * back to counting the view, which is the right way to be wrong.\n */\n private alreadySeen(postId: string): boolean {\n try {\n return sessionStorage.getItem(STORAGE_PREFIX + postId) !== null\n } catch {\n return false\n }\n }\n\n private remember(postId: string): void {\n try {\n sessionStorage.setItem(STORAGE_PREFIX + postId, '1')\n } catch {\n // No storage, so no memory. The server still deduplicates.\n }\n }\n\n private listenForUnload(): void {\n if (this.listening || typeof document === 'undefined') return\n this.listening = true\n /**\n * `visibilitychange`, not `unload`.\n *\n * `unload` does not fire reliably on mobile Safari, which is where a reader\n * most often leaves by switching apps rather than by closing a tab.\n */\n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState === 'hidden') this.flush()\n })\n }\n\n private clearTimer(): void {\n if (this.timer) clearTimeout(this.timer)\n this.timer = null\n }\n}\n\nfunction currentPath(): string {\n try {\n return window.location.pathname\n } catch {\n return '/'\n }\n}\n\nfunction nonce(): string {\n return Math.random().toString(36).slice(2, 10)\n}\n","'use client'\n\nimport { useEffect, useMemo, useRef } from 'react'\nimport { CMSKiteAnalytics, type TrackerOptions } from './analytics.js'\n\n/**\n * Hooks, for the one thing a React app genuinely needs help with.\n *\n * Fetching is not that thing. A server component awaits `cms.posts.list()` and\n * is done; a client component that needs data has a query library already, and\n * a `usePosts` here would be a worse one wearing our name. So there is no data\n * hook, deliberately.\n *\n * View tracking is that thing, because getting it right means knowing when a\n * component mounted, surviving React's double-invoked effects in development,\n * and not re-reporting on every re-render. That is exactly what a hook is for.\n */\n\n/**\n * Reports one view for one post, once.\n *\n * Safe in an effect that runs twice -- React's development Strict Mode\n * deliberately mounts, unmounts and remounts every component, and a naive\n * tracker counts that as two views. This reports on the first mount only, and\n * the tracker remembers the post in `sessionStorage` besides.\n *\n * export default function Article({ post }) {\n * useTrackView(post.id, { apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })\n * return <article dangerouslySetInnerHTML={{ __html: post.body }} />\n * }\n *\n * Passing a new `postId` reports the new post, which is what makes client-side\n * navigation between two articles count as two views without any router\n * integration.\n */\nexport function useTrackView(postId: string | null | undefined, options: TrackerOptions): void {\n const tracker = useTracker(options)\n const reported = useRef<string | null>(null)\n\n useEffect(() => {\n if (!postId || reported.current === postId) return\n reported.current = postId\n tracker.trackView(postId)\n }, [postId, tracker])\n}\n\n/**\n * The tracker itself, for click tracking and anything else bespoke.\n *\n * const analytics = useCMSKiteAnalytics({ apiKey })\n * <a onClick={() => analytics.trackClick(post.id, href)} href={href}>…</a>\n *\n * Memoised on the values that matter rather than on the options object, so a\n * caller passing an inline literal -- which is everybody -- does not rebuild\n * the tracker and lose its queue on every render.\n */\nexport function useCMSKiteAnalytics(options: TrackerOptions): CMSKiteAnalytics {\n return useTracker(options)\n}\n\nfunction useTracker(options: TrackerOptions): CMSKiteAnalytics {\n const { apiKey, baseUrl, enabled, flushIntervalMs } = options\n return useMemo(\n () =>\n new CMSKiteAnalytics({\n apiKey,\n ...(baseUrl === undefined ? {} : { baseUrl }),\n ...(enabled === undefined ? {} : { enabled }),\n ...(flushIntervalMs === undefined ? {} : { flushIntervalMs }),\n }),\n [apiKey, baseUrl, enabled, flushIntervalMs],\n )\n}\n"]}
|
package/dist/react.d.cts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { T as TrackerOptions, C as CMSKiteAnalytics } from './analytics-B0zzt0nW.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hooks, for the one thing a React app genuinely needs help with.
|
|
5
|
+
*
|
|
6
|
+
* Fetching is not that thing. A server component awaits `cms.posts.list()` and
|
|
7
|
+
* is done; a client component that needs data has a query library already, and
|
|
8
|
+
* a `usePosts` here would be a worse one wearing our name. So there is no data
|
|
9
|
+
* hook, deliberately.
|
|
10
|
+
*
|
|
11
|
+
* View tracking is that thing, because getting it right means knowing when a
|
|
12
|
+
* component mounted, surviving React's double-invoked effects in development,
|
|
13
|
+
* and not re-reporting on every re-render. That is exactly what a hook is for.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Reports one view for one post, once.
|
|
17
|
+
*
|
|
18
|
+
* Safe in an effect that runs twice -- React's development Strict Mode
|
|
19
|
+
* deliberately mounts, unmounts and remounts every component, and a naive
|
|
20
|
+
* tracker counts that as two views. This reports on the first mount only, and
|
|
21
|
+
* the tracker remembers the post in `sessionStorage` besides.
|
|
22
|
+
*
|
|
23
|
+
* export default function Article({ post }) {
|
|
24
|
+
* useTrackView(post.id, { apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })
|
|
25
|
+
* return <article dangerouslySetInnerHTML={{ __html: post.body }} />
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* Passing a new `postId` reports the new post, which is what makes client-side
|
|
29
|
+
* navigation between two articles count as two views without any router
|
|
30
|
+
* integration.
|
|
31
|
+
*/
|
|
32
|
+
declare function useTrackView(postId: string | null | undefined, options: TrackerOptions): void;
|
|
33
|
+
/**
|
|
34
|
+
* The tracker itself, for click tracking and anything else bespoke.
|
|
35
|
+
*
|
|
36
|
+
* const analytics = useCMSKiteAnalytics({ apiKey })
|
|
37
|
+
* <a onClick={() => analytics.trackClick(post.id, href)} href={href}>…</a>
|
|
38
|
+
*
|
|
39
|
+
* Memoised on the values that matter rather than on the options object, so a
|
|
40
|
+
* caller passing an inline literal -- which is everybody -- does not rebuild
|
|
41
|
+
* the tracker and lose its queue on every render.
|
|
42
|
+
*/
|
|
43
|
+
declare function useCMSKiteAnalytics(options: TrackerOptions): CMSKiteAnalytics;
|
|
44
|
+
|
|
45
|
+
export { useCMSKiteAnalytics, useTrackView };
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { T as TrackerOptions, C as CMSKiteAnalytics } from './analytics-B0zzt0nW.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hooks, for the one thing a React app genuinely needs help with.
|
|
5
|
+
*
|
|
6
|
+
* Fetching is not that thing. A server component awaits `cms.posts.list()` and
|
|
7
|
+
* is done; a client component that needs data has a query library already, and
|
|
8
|
+
* a `usePosts` here would be a worse one wearing our name. So there is no data
|
|
9
|
+
* hook, deliberately.
|
|
10
|
+
*
|
|
11
|
+
* View tracking is that thing, because getting it right means knowing when a
|
|
12
|
+
* component mounted, surviving React's double-invoked effects in development,
|
|
13
|
+
* and not re-reporting on every re-render. That is exactly what a hook is for.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Reports one view for one post, once.
|
|
17
|
+
*
|
|
18
|
+
* Safe in an effect that runs twice -- React's development Strict Mode
|
|
19
|
+
* deliberately mounts, unmounts and remounts every component, and a naive
|
|
20
|
+
* tracker counts that as two views. This reports on the first mount only, and
|
|
21
|
+
* the tracker remembers the post in `sessionStorage` besides.
|
|
22
|
+
*
|
|
23
|
+
* export default function Article({ post }) {
|
|
24
|
+
* useTrackView(post.id, { apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })
|
|
25
|
+
* return <article dangerouslySetInnerHTML={{ __html: post.body }} />
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* Passing a new `postId` reports the new post, which is what makes client-side
|
|
29
|
+
* navigation between two articles count as two views without any router
|
|
30
|
+
* integration.
|
|
31
|
+
*/
|
|
32
|
+
declare function useTrackView(postId: string | null | undefined, options: TrackerOptions): void;
|
|
33
|
+
/**
|
|
34
|
+
* The tracker itself, for click tracking and anything else bespoke.
|
|
35
|
+
*
|
|
36
|
+
* const analytics = useCMSKiteAnalytics({ apiKey })
|
|
37
|
+
* <a onClick={() => analytics.trackClick(post.id, href)} href={href}>…</a>
|
|
38
|
+
*
|
|
39
|
+
* Memoised on the values that matter rather than on the options object, so a
|
|
40
|
+
* caller passing an inline literal -- which is everybody -- does not rebuild
|
|
41
|
+
* the tracker and lose its queue on every render.
|
|
42
|
+
*/
|
|
43
|
+
declare function useCMSKiteAnalytics(options: TrackerOptions): CMSKiteAnalytics;
|
|
44
|
+
|
|
45
|
+
export { useCMSKiteAnalytics, useTrackView };
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CMSKiteAnalytics } from './chunk-QXWHDCMJ.js';
|
|
2
|
+
import './chunk-DT3V5CH2.js';
|
|
3
|
+
import { useRef, useEffect, useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
function useTrackView(postId, options) {
|
|
6
|
+
const tracker = useTracker(options);
|
|
7
|
+
const reported = useRef(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!postId || reported.current === postId) return;
|
|
10
|
+
reported.current = postId;
|
|
11
|
+
tracker.trackView(postId);
|
|
12
|
+
}, [postId, tracker]);
|
|
13
|
+
}
|
|
14
|
+
function useCMSKiteAnalytics(options) {
|
|
15
|
+
return useTracker(options);
|
|
16
|
+
}
|
|
17
|
+
function useTracker(options) {
|
|
18
|
+
const { apiKey, baseUrl, enabled, flushIntervalMs } = options;
|
|
19
|
+
return useMemo(
|
|
20
|
+
() => new CMSKiteAnalytics({
|
|
21
|
+
apiKey,
|
|
22
|
+
...baseUrl === void 0 ? {} : { baseUrl },
|
|
23
|
+
...enabled === void 0 ? {} : { enabled },
|
|
24
|
+
...flushIntervalMs === void 0 ? {} : { flushIntervalMs }
|
|
25
|
+
}),
|
|
26
|
+
[apiKey, baseUrl, enabled, flushIntervalMs]
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { useCMSKiteAnalytics, useTrackView };
|
|
31
|
+
//# sourceMappingURL=react.js.map
|
|
32
|
+
//# sourceMappingURL=react.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/react.ts"],"names":[],"mappings":";;;;AAmCO,SAAS,YAAA,CAAa,QAAmC,OAAA,EAA+B;AAC7F,EAAA,MAAM,OAAA,GAAU,WAAW,OAAO,CAAA;AAClC,EAAA,MAAM,QAAA,GAAW,OAAsB,IAAI,CAAA;AAE3C,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,IAAI,CAAC,MAAA,IAAU,QAAA,CAAS,OAAA,KAAY,MAAA,EAAQ;AAC5C,IAAA,QAAA,CAAS,OAAA,GAAU,MAAA;AACnB,IAAA,OAAA,CAAQ,UAAU,MAAM,CAAA;AAAA,EAC1B,CAAA,EAAG,CAAC,MAAA,EAAQ,OAAO,CAAC,CAAA;AACtB;AAYO,SAAS,oBAAoB,OAAA,EAA2C;AAC7E,EAAA,OAAO,WAAW,OAAO,CAAA;AAC3B;AAEA,SAAS,WAAW,OAAA,EAA2C;AAC7D,EAAA,MAAM,EAAE,MAAA,EAAQ,OAAA,EAAS,OAAA,EAAS,iBAAgB,GAAI,OAAA;AACtD,EAAA,OAAO,OAAA;AAAA,IACL,MACE,IAAI,gBAAA,CAAiB;AAAA,MACnB,MAAA;AAAA,MACA,GAAI,OAAA,KAAY,MAAA,GAAY,EAAC,GAAI,EAAE,OAAA,EAAQ;AAAA,MAC3C,GAAI,OAAA,KAAY,MAAA,GAAY,EAAC,GAAI,EAAE,OAAA,EAAQ;AAAA,MAC3C,GAAI,eAAA,KAAoB,MAAA,GAAY,EAAC,GAAI,EAAE,eAAA;AAAgB,KAC5D,CAAA;AAAA,IACH,CAAC,MAAA,EAAQ,OAAA,EAAS,OAAA,EAAS,eAAe;AAAA,GAC5C;AACF","file":"react.js","sourcesContent":["'use client'\n\nimport { useEffect, useMemo, useRef } from 'react'\nimport { CMSKiteAnalytics, type TrackerOptions } from './analytics.js'\n\n/**\n * Hooks, for the one thing a React app genuinely needs help with.\n *\n * Fetching is not that thing. A server component awaits `cms.posts.list()` and\n * is done; a client component that needs data has a query library already, and\n * a `usePosts` here would be a worse one wearing our name. So there is no data\n * hook, deliberately.\n *\n * View tracking is that thing, because getting it right means knowing when a\n * component mounted, surviving React's double-invoked effects in development,\n * and not re-reporting on every re-render. That is exactly what a hook is for.\n */\n\n/**\n * Reports one view for one post, once.\n *\n * Safe in an effect that runs twice -- React's development Strict Mode\n * deliberately mounts, unmounts and remounts every component, and a naive\n * tracker counts that as two views. This reports on the first mount only, and\n * the tracker remembers the post in `sessionStorage` besides.\n *\n * export default function Article({ post }) {\n * useTrackView(post.id, { apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })\n * return <article dangerouslySetInnerHTML={{ __html: post.body }} />\n * }\n *\n * Passing a new `postId` reports the new post, which is what makes client-side\n * navigation between two articles count as two views without any router\n * integration.\n */\nexport function useTrackView(postId: string | null | undefined, options: TrackerOptions): void {\n const tracker = useTracker(options)\n const reported = useRef<string | null>(null)\n\n useEffect(() => {\n if (!postId || reported.current === postId) return\n reported.current = postId\n tracker.trackView(postId)\n }, [postId, tracker])\n}\n\n/**\n * The tracker itself, for click tracking and anything else bespoke.\n *\n * const analytics = useCMSKiteAnalytics({ apiKey })\n * <a onClick={() => analytics.trackClick(post.id, href)} href={href}>…</a>\n *\n * Memoised on the values that matter rather than on the options object, so a\n * caller passing an inline literal -- which is everybody -- does not rebuild\n * the tracker and lose its queue on every render.\n */\nexport function useCMSKiteAnalytics(options: TrackerOptions): CMSKiteAnalytics {\n return useTracker(options)\n}\n\nfunction useTracker(options: TrackerOptions): CMSKiteAnalytics {\n const { apiKey, baseUrl, enabled, flushIntervalMs } = options\n return useMemo(\n () =>\n new CMSKiteAnalytics({\n apiKey,\n ...(baseUrl === undefined ? {} : { baseUrl }),\n ...(enabled === undefined ? {} : { enabled }),\n ...(flushIntervalMs === undefined ? {} : { flushIntervalMs }),\n }),\n [apiKey, baseUrl, enabled, flushIntervalMs],\n )\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cmskite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The official CMSKite client. Fetch your posts and measure who reads them, in a few lines.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://cmskite.com/docs",
|
|
7
|
+
"repository": { "type": "git", "url": "https://github.com/devicorn/cmskite-sdk.git" },
|
|
8
|
+
"keywords": ["cmskite", "cms", "headless-cms", "blog", "content-api", "analytics"],
|
|
9
|
+
"type": "module",
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"require": "./dist/index.cjs"
|
|
16
|
+
},
|
|
17
|
+
"./browser": {
|
|
18
|
+
"types": "./dist/browser.d.ts",
|
|
19
|
+
"import": "./dist/browser.js",
|
|
20
|
+
"require": "./dist/browser.cjs"
|
|
21
|
+
},
|
|
22
|
+
"./react": {
|
|
23
|
+
"types": "./dist/react.d.ts",
|
|
24
|
+
"import": "./dist/react.js",
|
|
25
|
+
"require": "./dist/react.cjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.cjs",
|
|
29
|
+
"module": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
32
|
+
"engines": { "node": ">=18" },
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsup",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"lint": "eslint src",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"react": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"react": { "optional": true }
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^24.10.1",
|
|
48
|
+
"@types/react": "^19.2.0",
|
|
49
|
+
"react": "^19.2.0",
|
|
50
|
+
"tsup": "^8.5.0",
|
|
51
|
+
"typescript": "~5.9.3",
|
|
52
|
+
"vitest": "^3.2.4"
|
|
53
|
+
}
|
|
54
|
+
}
|