@getrefino/react 0.1.0-rc.1 → 0.1.0-rc.2

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.
@@ -1,6 +1,7 @@
1
1
  import type { CopyContent } from "@getrefino/core";
2
2
  import type { JSX, ReactNode } from "react";
3
3
  import type { CopyRequestHeaders } from "./store.js";
4
+ import type { EditorEventSink } from "./telemetry.js";
4
5
  export interface RefinoProviderProps {
5
6
  /** The canonical copy, usually `import copy from "./content/copy.json"`. */
6
7
  content: CopyContent;
@@ -23,6 +24,14 @@ export interface RefinoProviderProps {
23
24
  signInHref?: string | undefined;
24
25
  /** Called when the user presses Exit (after confirming unsaved changes). */
25
26
  onExit?: (() => void | Promise<void>) | undefined;
27
+ /**
28
+ * Optional sink for the editor's four semantic events (`editor_opened`,
29
+ * `edit_started`, `edit_saved`, `edit_reverted`). No SDK is loaded and no
30
+ * request is made unless a site provides one, and the events carry counts
31
+ * and enums only — never copy, ids or page content. Refino-connected
32
+ * sites get a sink that posts to Refino; everyone else gets silence.
33
+ */
34
+ onEvent?: EditorEventSink | undefined;
26
35
  /** Render the built-in toolbar in edit mode. Set false to use `useCopyEditor` with custom UI. */
27
36
  toolbar?: boolean | undefined;
28
37
  children?: ReactNode | undefined;
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { getDirtyIds } from "@getrefino/core";
4
- import { useEffect, useMemo, useState } from "react";
4
+ import { useEffect, useMemo, useRef, useState } from "react";
5
5
  import { CopyContext } from "./context.js";
6
6
  import { createCopyStore } from "./store.js";
7
7
  import { EDITOR_STYLES } from "./styles.js";
@@ -13,8 +13,13 @@ import { CopyEditorToolbar } from "./Toolbar.js";
13
13
  * the toolbar and editor styles.
14
14
  */
15
15
  export function RefinoProvider(props) {
16
- const { content, editing = false, endpoint = "/api/copy", headers, signInHref = "/edit", onExit, toolbar = true, children } = props;
17
- const [store] = useState(() => createCopyStore({ content, editing, endpoint, headers }));
16
+ const { content, editing = false, endpoint = "/api/copy", headers, signInHref = "/edit", onExit, onEvent, toolbar = true, children } = props;
17
+ // Created once. The sink is read through the ref below, so a host that
18
+ // passes a new closure on every render neither recreates the store nor
19
+ // replays a lifecycle event.
20
+ const sinkRef = useRef(onEvent);
21
+ sinkRef.current = onEvent;
22
+ const [store] = useState(() => createCopyStore({ content, editing, endpoint, headers, onEvent: (event) => sinkRef.current?.(event) }));
18
23
  useEffect(() => {
19
24
  store.setEditing(editing);
20
25
  }, [editing, store]);
package/dist/index.d.ts CHANGED
@@ -7,4 +7,5 @@ export { useCopyEditor } from "./useCopyEditor.js";
7
7
  export type { CopyEditor } from "./useCopyEditor.js";
8
8
  export { createCopyStore } from "./store.js";
9
9
  export type { CopyEditorState, CopyRequestHeaders, CopyStore, CopyStoreOptions, EditorError, EditorStatus, LastSave, RebaseInfo, } from "./store.js";
10
+ export type { EditorEvent, EditorEventName, EditorEventProperties, EditorEventSink } from "./telemetry.js";
10
11
  export { EDITOR_STYLES } from "./styles.js";
package/dist/store.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ContentId, ContentSnapshot, CopyContent, DraftState } from "@getrefino/core";
2
+ import type { EditorEventSink } from "./telemetry.js";
2
3
  export type EditorStatus = "idle" | "loading" | "saving" | "saved" | "error" | "conflict";
3
4
  export interface EditorError {
4
5
  readonly code: string;
@@ -44,6 +45,12 @@ export interface CopyStoreOptions {
44
45
  */
45
46
  readonly headers?: CopyRequestHeaders | undefined;
46
47
  readonly fetch?: typeof fetch;
48
+ /**
49
+ * Optional sink for the editor's four semantic events. Called only on real
50
+ * state transitions — never on a render, a keystroke, a poll or a retry —
51
+ * and never with copy, ids or anything read from the page.
52
+ */
53
+ readonly onEvent?: EditorEventSink | undefined;
47
54
  }
48
55
  export interface CopyStore {
49
56
  getState(): CopyEditorState;
package/dist/store.js CHANGED
@@ -2,6 +2,36 @@ import { createDraft, getDirtyIds, getDraftValue, hasCopy, isDraftValueDirty, re
2
2
  export function createCopyStore(options) {
3
3
  const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
4
4
  const listeners = new Set();
5
+ /**
6
+ * Whether this editing session has already been reported as opened.
7
+ *
8
+ * An **editor session** starts when edit mode turns on and the canonical
9
+ * copy has loaded, and ends when edit mode turns off. Edit mode is a
10
+ * product state — decided by the host's server check, or in Refino-hosted
11
+ * mode by whether a valid editor token is stored — so a session is a fact
12
+ * about the product, never about React.
13
+ *
14
+ * That is why the flag is keyed to `editing` and not to the store's
15
+ * lifetime: within one session, a re-render, a StrictMode double effect,
16
+ * a retried `load()` or a `reloadLatest()` after a conflict must not
17
+ * report a second opening, while genuinely leaving edit mode and coming
18
+ * back must.
19
+ */
20
+ let openedReported = false;
21
+ function report(event) {
22
+ const sink = options.onEvent;
23
+ if (!sink)
24
+ return;
25
+ try {
26
+ sink(event);
27
+ }
28
+ catch {
29
+ // Telemetry must never break editing.
30
+ }
31
+ }
32
+ function dirtyCount(draft) {
33
+ return Object.keys(draft.changes).length;
34
+ }
5
35
  let state = {
6
36
  editing: options.editing,
7
37
  draft: createDraft(options.content),
@@ -89,19 +119,29 @@ export function createCopyStore(options) {
89
119
  const draft = setDraftValue(state.draft, id, value);
90
120
  if (draft === state.draft)
91
121
  return;
122
+ // The transition from a clean draft to a dirty one, not every
123
+ // keystroke: typing a word produces one event, not twenty.
124
+ const started = dirtyCount(state.draft) === 0 && dirtyCount(draft) > 0;
92
125
  update({
93
126
  draft,
94
127
  status: state.status === "saved" || state.status === "error" ? "idle" : state.status,
95
128
  error: state.status === "error" ? null : state.error,
96
129
  });
130
+ if (started)
131
+ report({ name: "edit_started", properties: {} });
97
132
  },
98
133
  revertValue(id) {
99
134
  const draft = revertDraftValue(state.draft, id);
100
- if (draft !== state.draft)
101
- update({ draft });
135
+ if (draft === state.draft)
136
+ return;
137
+ update({ draft });
138
+ report({ name: "edit_reverted", properties: { scope: "one" } });
102
139
  },
103
140
  revertAll() {
141
+ const had = dirtyCount(state.draft) > 0;
104
142
  update({ draft: revertAllDraft(state.draft), rebase: null });
143
+ if (had)
144
+ report({ name: "edit_reverted", properties: { scope: "all" } });
105
145
  },
106
146
  setActive(id) {
107
147
  if (state.activeId !== id)
@@ -113,8 +153,13 @@ export function createCopyStore(options) {
113
153
  update({ draft: createDraft(content) });
114
154
  },
115
155
  setEditing(editing) {
116
- if (state.editing !== editing)
117
- update({ editing });
156
+ if (state.editing === editing)
157
+ return;
158
+ // Leaving edit mode ends the session. The next one is a new session and
159
+ // reports its own opening, whether or not the provider ever unmounted.
160
+ if (!editing)
161
+ openedReported = false;
162
+ update({ editing });
118
163
  },
119
164
  async load() {
120
165
  update({ status: "loading", error: null });
@@ -122,6 +167,11 @@ export function createCopyStore(options) {
122
167
  if (body.ok && "snapshot" in body) {
123
168
  applySnapshot(body.snapshot, getDirtyIds(state.draft).length > 0);
124
169
  update({ status: "idle" });
170
+ // The editor is open once the canonical copy is actually in hand.
171
+ if (state.editing && !openedReported) {
172
+ openedReported = true;
173
+ report({ name: "editor_opened", properties: {} });
174
+ }
125
175
  }
126
176
  else if (!body.ok) {
127
177
  update({ status: "error", error: body.error });
@@ -138,7 +188,8 @@ export function createCopyStore(options) {
138
188
  return;
139
189
  }
140
190
  const changes = state.draft.changes;
141
- if (Object.keys(changes).length === 0) {
191
+ const changedCount = Object.keys(changes).length;
192
+ if (changedCount === 0) {
142
193
  update({ status: "saved", lastSave: { at: Date.now(), status: "unchanged" }, error: null });
143
194
  return;
144
195
  }
@@ -155,6 +206,11 @@ export function createCopyStore(options) {
155
206
  ? { at: Date.now(), status: result.status, commitUrl: result.commit.url }
156
207
  : { at: Date.now(), status: result.status };
157
208
  update({ status: "saved", lastSave });
209
+ // Only a save that produced a commit. An unchanged re-save wrote
210
+ // nothing, so it is not an edit.
211
+ if (result.status === "saved") {
212
+ report({ name: "edit_saved", properties: { changed_count: changedCount } });
213
+ }
158
214
  return;
159
215
  }
160
216
  if (!body.ok && body.error.code === "CONFLICT") {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The editor's semantic event sink.
3
+ *
4
+ * `@getrefino/react` ships to customers' own websites, so it knows nothing
5
+ * about any analytics vendor and loads no SDK. It reports four facts about
6
+ * the editor's own state machine and hands them to whatever function the
7
+ * host passed in; a site that passes nothing sends nothing.
8
+ *
9
+ * What it reports is deliberately tiny and deliberately content-free: a
10
+ * count and an enum, never a copy id, never a draft value, never the
11
+ * canonical text, never anything read out of the customer's page. The
12
+ * properties an event may carry are listed here, in types, for exactly
13
+ * that reason.
14
+ */
15
+ export type EditorEventName =
16
+ /** Edit mode is on and the canonical copy has loaded. Once per editor session. */
17
+ "editor_opened"
18
+ /** The draft went from clean to dirty. Once per run of changes, not once per keystroke. */
19
+ | "edit_started"
20
+ /** A save succeeded. Hosts that persist through Refino leave this to the server. */
21
+ | "edit_saved"
22
+ /** A draft change was thrown away. */
23
+ | "edit_reverted";
24
+ export interface EditorEventProperties {
25
+ editor_opened: Record<string, never>;
26
+ edit_started: Record<string, never>;
27
+ /** How many ids were in the save. A count, never the ids. */
28
+ edit_saved: {
29
+ readonly changed_count: number;
30
+ };
31
+ edit_reverted: {
32
+ readonly scope: "one" | "all";
33
+ };
34
+ }
35
+ export type EditorEvent = {
36
+ [E in EditorEventName]: {
37
+ readonly name: E;
38
+ readonly properties: EditorEventProperties[E];
39
+ };
40
+ }[EditorEventName];
41
+ /**
42
+ * Called once per editor state transition. It must not throw — the store
43
+ * calls it inside a guard anyway, so a sink that does is simply ignored —
44
+ * and it must not block: treat it as fire and forget.
45
+ */
46
+ export type EditorEventSink = (event: EditorEvent) => void;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The editor's semantic event sink.
3
+ *
4
+ * `@getrefino/react` ships to customers' own websites, so it knows nothing
5
+ * about any analytics vendor and loads no SDK. It reports four facts about
6
+ * the editor's own state machine and hands them to whatever function the
7
+ * host passed in; a site that passes nothing sends nothing.
8
+ *
9
+ * What it reports is deliberately tiny and deliberately content-free: a
10
+ * count and an enum, never a copy id, never a draft value, never the
11
+ * canonical text, never anything read out of the customer's page. The
12
+ * properties an event may carry are listed here, in types, for exactly
13
+ * that reason.
14
+ */
15
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrefino/react",
3
- "version": "0.1.0-rc.1",
3
+ "version": "0.1.0-rc.2",
4
4
  "description": "React components for Refino: edit a website's existing copy in place on the rendered page and commit it to the repository. Click text. Edit text. Save.",
5
5
  "keywords": [
6
6
  "refino",
@@ -37,7 +37,7 @@
37
37
  "./package.json": "./package.json"
38
38
  },
39
39
  "dependencies": {
40
- "@getrefino/core": "0.1.0-rc.1"
40
+ "@getrefino/core": "0.1.0-rc.2"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "react": "^18.2.0 || ^19.0.0",