@getrefino/react 0.1.0-rc.1 → 0.1.0-rc.3
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/dist/RefinoProvider.d.ts +9 -0
- package/dist/RefinoProvider.js +8 -3
- package/dist/index.d.ts +1 -0
- package/dist/store.d.ts +7 -0
- package/dist/store.js +61 -5
- package/dist/telemetry.d.ts +46 -0
- package/dist/telemetry.js +15 -0
- package/package.json +2 -2
package/dist/RefinoProvider.d.ts
CHANGED
|
@@ -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;
|
package/dist/RefinoProvider.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
101
|
-
|
|
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
|
|
117
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.0-rc.3",
|
|
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.
|
|
40
|
+
"@getrefino/core": "0.1.0-rc.3"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"react": "^18.2.0 || ^19.0.0",
|