@getrefino/onboarding 0.1.0-rc.3 → 0.1.0-rc.5

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/semver.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Just enough semver for comparing Refino release versions: exact versions
3
+ * only (`1.2.3`, `0.1.0-rc.4`), ordered by the semver 2.0 precedence rules.
4
+ * Ranges are never interpreted here; code that meets one treats it as
5
+ * something to preserve or refuse, not something to reason about.
6
+ */
7
+ const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
8
+ function parse(version) {
9
+ const match = VERSION.exec(version);
10
+ if (!match)
11
+ return null;
12
+ return {
13
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
14
+ prerelease: match[4] ? match[4].split(".") : [],
15
+ };
16
+ }
17
+ /** Whether `value` is one exact version (no range, no tag, no `v` prefix). */
18
+ export function isExactVersion(value) {
19
+ return parse(value) !== null;
20
+ }
21
+ function compareIdentifiers(a, b) {
22
+ const numericA = /^\d+$/.test(a);
23
+ const numericB = /^\d+$/.test(b);
24
+ if (numericA && numericB)
25
+ return Math.sign(Number(a) - Number(b));
26
+ if (numericA)
27
+ return -1;
28
+ if (numericB)
29
+ return 1;
30
+ return a < b ? -1 : a > b ? 1 : 0;
31
+ }
32
+ /**
33
+ * Negative when `a` precedes `b`, positive when it follows, zero when they
34
+ * are the same release. Throws on anything that is not an exact version, so
35
+ * a caller can never mistake "could not compare" for "equal".
36
+ */
37
+ export function compareVersions(a, b) {
38
+ const left = parse(a);
39
+ const right = parse(b);
40
+ if (!left || !right)
41
+ throw new Error(`Cannot compare ${JSON.stringify(a)} with ${JSON.stringify(b)}: both must be exact versions.`);
42
+ for (let index = 0; index < 3; index += 1) {
43
+ const difference = left.core[index] - right.core[index];
44
+ if (difference !== 0)
45
+ return Math.sign(difference);
46
+ }
47
+ // A release follows every one of its prereleases.
48
+ if (left.prerelease.length === 0 || right.prerelease.length === 0) {
49
+ return Math.sign(right.prerelease.length - left.prerelease.length);
50
+ }
51
+ const length = Math.max(left.prerelease.length, right.prerelease.length);
52
+ for (let index = 0; index < length; index += 1) {
53
+ const x = left.prerelease[index];
54
+ const y = right.prerelease[index];
55
+ if (x === undefined)
56
+ return -1;
57
+ if (y === undefined)
58
+ return 1;
59
+ const result = compareIdentifiers(x, y);
60
+ if (result !== 0)
61
+ return result;
62
+ }
63
+ return 0;
64
+ }
@@ -10,7 +10,7 @@
10
10
  * earlier Refino's output" a byte comparison in both languages.
11
11
  */
12
12
  import ts from "typescript";
13
- import { TOOL_VERSION } from "./plan.js";
13
+ import { TOOL_VERSION } from "./constants.js";
14
14
  function transpile(path, source) {
15
15
  const output = ts.transpileModule(source, {
16
16
  compilerOptions: {
@@ -17,9 +17,9 @@ export declare const REFINO_SITE_ID_PATTERN: RegExp;
17
17
  /** Public configuration; safe to ship to the browser and to commit. */
18
18
  export declare const REFINO_SITE: (config: RefinoSiteConfig) => string;
19
19
  /** Browser-only client: PKCE, the authorization round trip and the session in sessionStorage. */
20
- export declare const REFINO_CLIENT = "/**\n * Browser-side Refino editor authorization for this site. No secrets here:\n * the site is a public client (OAuth 2.1 with PKCE). The editor token lives\n * in sessionStorage only (never localStorage, never a URL), is scoped to\n * this site, and Refino re-checks the account's entitlement on every load\n * and save. Never import this from server code.\n */\nimport { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from \"./refino-site\";\n\nconst PKCE_KEY = \"refino.pkce\";\nconst SESSION_KEY = \"refino.editor\";\nconst CALLBACK_PATH = \"/edit\";\n\nexport interface EditorSession {\n readonly token: string;\n /** Unix seconds. */\n readonly expiresAt: number;\n readonly siteId: string;\n}\n\ninterface PendingAuthorization {\n readonly verifier: string;\n readonly state: string;\n readonly returnTo: string;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction randomToken(bytes: number): string {\n const buffer = new Uint8Array(bytes);\n crypto.getRandomValues(buffer);\n return base64url(buffer);\n}\n\nasync function sha256(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text));\n return base64url(new Uint8Array(digest));\n}\n\nfunction storage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nfunction readJson<T>(key: string): T | null {\n const raw = storage()?.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Only a path on this site, never another origin. */\nexport function safeReturnTo(value: string | null | undefined): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\") || value.startsWith(\"/\\\\\") || value.length > 512) return \"/\";\n return value;\n}\n\nexport function editorEndpoint(): string {\n return `${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/copy`;\n}\n\nexport function callbackUrl(): string {\n return `${window.location.origin}${CALLBACK_PATH}`;\n}\n\n/** The current editor session, or null when there is none or it expired. */\nexport function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {\n const session = readJson<EditorSession>(SESSION_KEY);\n if (!session || typeof session.token !== \"string\" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== \"number\") return null;\n if (session.expiresAt <= nowSeconds) {\n storage()?.removeItem(SESSION_KEY);\n return null;\n }\n return session;\n}\n\nexport function clearEditorSession(): void {\n storage()?.removeItem(SESSION_KEY);\n storage()?.removeItem(PKCE_KEY);\n}\n\n/** Start the flow: remember the PKCE verifier and state, then go to Refino. */\nexport async function beginAuthorization(returnTo: string): Promise<void> {\n const verifier = randomToken(32);\n const state = randomToken(16);\n const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };\n storage()?.setItem(PKCE_KEY, JSON.stringify(pending));\n const params = new URLSearchParams({\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n code_challenge: await sha256(verifier),\n code_challenge_method: \"S256\",\n state,\n });\n window.location.assign(`${REFINO_APP_URL}/editor/authorize?${params.toString()}`);\n}\n\nexport type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };\n\n/**\n * Finish the flow on /edit?code&state: check the state, exchange the code\n * with the verifier this browser kept, store the session, and scrub the\n * code from the URL. The token is never placed in a URL.\n */\nexport async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {\n const params = new URLSearchParams(search);\n const code = params.get(\"code\");\n const state = params.get(\"state\");\n const pending = readJson<PendingAuthorization>(PKCE_KEY);\n storage()?.removeItem(PKCE_KEY);\n if (typeof window !== \"undefined\" && window.history.replaceState) window.history.replaceState(null, \"\", window.location.pathname);\n if (!code || !state) return { ok: false, error: \"Refino did not return an authorization code.\" };\n if (!pending || pending.state !== state) return { ok: false, error: \"Sign-in was interrupted (state mismatch). Start again.\" };\n\n let response: Response;\n try {\n response = await fetch(`${REFINO_APP_URL}/api/editor/token`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify({\n grant_type: \"authorization_code\",\n code,\n code_verifier: pending.verifier,\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n // The public install facts from refino-site.ts, sent once per\n // sign-in. Refino records them the first time and ignores them\n // afterwards. Remove this line and everything still works.\n install: REFINO_INSTALL,\n }),\n });\n } catch {\n return { ok: false, error: \"Could not reach Refino to finish signing in.\" };\n }\n const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;\n if (!response.ok || !body?.ok || typeof body.token !== \"string\" || typeof body.expiresAt !== \"number\" || body.siteId !== REFINO_SITE_ID) {\n return { ok: false, error: body?.error?.message ?? \"Refino did not accept the authorization.\" };\n }\n const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };\n storage()?.setItem(SESSION_KEY, JSON.stringify(session));\n return { ok: true, returnTo: pending.returnTo };\n}\n\n/**\n * The three editor events this site reports, and the only ones it ever\n * sends. There is no analytics SDK on this site: no autocapture, no session\n * recording, no third-party script, no cookie. Each event is one small POST\n * to Refino carrying an event name and, for a revert, whether it was one\n * word or all of them \u2014 never your page, your copy, your visitors or your\n * repository. `edit_saved` is not in this list on purpose: a save is a\n * commit, and Refino records that on its own side where it cannot be faked\n * or lost.\n */\nconst REPORTABLE_EVENTS = [\"editor_opened\", \"edit_started\", \"edit_reverted\"] as const;\n\ntype ReportableEvent = (typeof REPORTABLE_EVENTS)[number];\n\ninterface EditorEventLike {\n readonly name: string;\n readonly properties: Readonly<Record<string, unknown>>;\n}\n\n/**\n * Fire and forget. A failure here is invisible and harmless: nothing in the\n * editor waits for it, retries it, or behaves differently when it fails.\n */\nexport function reportEditorEvent(event: EditorEventLike): void {\n const session = readEditorSession();\n if (!session) return;\n if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;\n const name = event.name as ReportableEvent;\n const scope = event.properties.scope;\n try {\n void fetch(`${REFINO_APP_URL}/api/editor/events`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", authorization: `Bearer ${session.token}` },\n body: JSON.stringify(scope === \"one\" || scope === \"all\" ? { event: name, scope } : { event: name }),\n }).catch(() => {});\n } catch {\n // Never let reporting affect editing.\n }\n}\n";
20
+ export declare const REFINO_CLIENT = "/**\n * Browser-side Refino editor authorization for this site. No secrets here:\n * the site is a public client (OAuth 2.1 with PKCE). The editor token lives\n * in sessionStorage only (never localStorage, never a URL), is scoped to\n * this site, and Refino re-checks the account's entitlement on every load\n * and save. Never import this from server code.\n */\nimport { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from \"./refino-site\";\n\nconst PKCE_KEY = \"refino.pkce\";\nconst SESSION_KEY = \"refino.editor\";\nconst CALLBACK_PATH = \"/edit\";\n\nexport interface EditorSession {\n readonly token: string;\n /** Unix seconds. */\n readonly expiresAt: number;\n readonly siteId: string;\n}\n\ninterface PendingAuthorization {\n readonly verifier: string;\n readonly state: string;\n readonly returnTo: string;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction randomToken(bytes: number): string {\n const buffer = new Uint8Array(bytes);\n crypto.getRandomValues(buffer);\n return base64url(buffer);\n}\n\nasync function sha256(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text));\n return base64url(new Uint8Array(digest));\n}\n\nfunction storage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nfunction readJson<T>(key: string): T | null {\n const raw = storage()?.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Only a path on this site, never another origin. */\nexport function safeReturnTo(value: string | null | undefined): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\") || value.startsWith(\"/\\\\\") || value.length > 512) return \"/\";\n return value;\n}\n\nexport function editorEndpoint(): string {\n return `${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/copy`;\n}\n\nexport function callbackUrl(): string {\n return `${window.location.origin}${CALLBACK_PATH}`;\n}\n\n/** The current editor session, or null when there is none or it expired. */\nexport function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {\n const session = readJson<EditorSession>(SESSION_KEY);\n if (!session || typeof session.token !== \"string\" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== \"number\") return null;\n if (session.expiresAt <= nowSeconds) {\n storage()?.removeItem(SESSION_KEY);\n return null;\n }\n return session;\n}\n\nexport function clearEditorSession(): void {\n storage()?.removeItem(SESSION_KEY);\n storage()?.removeItem(PKCE_KEY);\n}\n\n/** Start the flow: remember the PKCE verifier and state, then go to Refino. */\nexport async function beginAuthorization(returnTo: string): Promise<void> {\n const verifier = randomToken(32);\n const state = randomToken(16);\n const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };\n storage()?.setItem(PKCE_KEY, JSON.stringify(pending));\n const params = new URLSearchParams({\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n code_challenge: await sha256(verifier),\n code_challenge_method: \"S256\",\n state,\n });\n window.location.assign(`${REFINO_APP_URL}/editor/authorize?${params.toString()}`);\n}\n\nexport type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };\n\n/**\n * Finish the flow on /edit?code&state: check the state, exchange the code\n * with the verifier this browser kept, store the session, and scrub the\n * code from the URL. The token is never placed in a URL.\n */\nexport async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {\n const params = new URLSearchParams(search);\n const code = params.get(\"code\");\n const state = params.get(\"state\");\n const pending = readJson<PendingAuthorization>(PKCE_KEY);\n storage()?.removeItem(PKCE_KEY);\n if (typeof window !== \"undefined\" && window.history.replaceState) window.history.replaceState(null, \"\", window.location.pathname);\n if (!code || !state) return { ok: false, error: \"Refino did not return an authorization code.\" };\n if (!pending || pending.state !== state) return { ok: false, error: \"Sign-in was interrupted (state mismatch). Start again.\" };\n\n let response: Response;\n try {\n response = await fetch(`${REFINO_APP_URL}/api/editor/token`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify({\n grant_type: \"authorization_code\",\n code,\n code_verifier: pending.verifier,\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n // The public install facts from refino-site.ts, sent once per\n // sign-in. Refino records them the first time and ignores them\n // afterwards. Remove this line and everything still works.\n install: REFINO_INSTALL,\n }),\n });\n } catch {\n return { ok: false, error: \"Could not reach Refino to finish signing in.\" };\n }\n const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;\n if (!response.ok || !body?.ok || typeof body.token !== \"string\" || typeof body.expiresAt !== \"number\" || body.siteId !== REFINO_SITE_ID) {\n return { ok: false, error: body?.error?.message ?? \"Refino did not accept the authorization.\" };\n }\n const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };\n storage()?.setItem(SESSION_KEY, JSON.stringify(session));\n return { ok: true, returnTo: pending.returnTo };\n}\n\n/**\n * The three editor events this site reports, and the only ones it ever\n * sends. There is no analytics SDK on this site: no autocapture, no session\n * recording, no third-party script, no cookie. Each event is one small POST\n * to Refino carrying an event name and, for a revert, whether it was one\n * word or all of them \u2014 never your page, your copy, your visitors or your\n * repository. `edit_saved` is not in this list on purpose: a save is a\n * commit, and Refino records that on its own side where it cannot be faked\n * or lost.\n */\nconst REPORTABLE_EVENTS = [\"editor_opened\", \"edit_started\", \"edit_reverted\"] as const;\n\ntype ReportableEvent = (typeof REPORTABLE_EVENTS)[number];\n\ninterface EditorEventLike {\n readonly name: string;\n readonly properties: Readonly<Record<string, unknown>>;\n}\n\n/**\n * Fire and forget. A failure here is invisible and harmless: nothing in the\n * editor waits for it, retries it, or behaves differently when it fails.\n */\nexport function reportEditorEvent(event: EditorEventLike): void {\n const session = readEditorSession();\n if (!session) return;\n if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;\n const name = event.name as ReportableEvent;\n const scope = event.properties.scope;\n try {\n void fetch(`${REFINO_APP_URL}/api/editor/events`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", authorization: `Bearer ${session.token}` },\n body: JSON.stringify(scope === \"one\" || scope === \"all\" ? { event: name, scope } : { event: name }),\n }).catch(() => {});\n } catch {\n // Never let reporting affect editing.\n }\n}\n\n/**\n * Updates to this site's Refino integration, offered in the editor\n * toolbar. Asking is read-only. Nothing changes unless the owner presses\n * \"Update Refino\": Refino then commits the new Refino version to this\n * repository, and the site's normal deployment takes it from there. The\n * editor checks every answer itself, so this stays a plain pipe.\n */\nexport const refinoUpdates = {\n check: (): Promise<unknown> => updateRequest(\"GET\"),\n apply: (version: string): Promise<unknown> => updateRequest(\"POST\", JSON.stringify({ version })),\n};\n\nasync function updateRequest(method: \"GET\" | \"POST\", body?: string): Promise<unknown> {\n const session = readEditorSession();\n if (!session) return null;\n const headers: Record<string, string> = { accept: \"application/json\", authorization: `Bearer ${session.token}` };\n if (body) headers[\"content-type\"] = \"application/json\";\n try {\n const response = await fetch(`${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/update`, { method, mode: \"cors\", credentials: \"omit\", headers, body });\n return await response.json();\n } catch {\n // Unreachable or unreadable: the editor treats it as \"nothing to offer\", or as an unknown outcome.\n return null;\n }\n}\n";
21
21
  /** Provider wrapper: edit mode is decided in the browser from the stored session. */
22
- export declare const COPY_EDITING_HOSTED = "\"use client\";\n\nimport type { CopyContent } from \"@getrefino/core\";\nimport { RefinoProvider } from \"@getrefino/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport { clearEditorSession, editorEndpoint, readEditorSession, reportEditorEvent } from \"./refino-client\";\nimport type { EditorSession } from \"./refino-client\";\n\ninterface CopyEditingProps {\n content: CopyContent;\n children: ReactNode;\n}\n\n/**\n * Hosted mode (Refino). Visitors get the plain site: the server never\n * decides edit mode, so pages can stay static. In the browser, an editor\n * session stored by /edit turns edit mode on; loads and saves go to Refino\n * with the session's bearer token, and Refino checks the site's\n * entitlement on every request.\n */\nexport function CopyEditing({ content, children }: CopyEditingProps) {\n const [session, setSession] = useState<EditorSession | null>(null);\n\n useEffect(() => {\n // Read the session only after mount so server-rendered HTML never differs from the visitor's.\n setSession(readEditorSession());\n }, []);\n\n // Read at request time (the provider keeps its options from the first render), so the\n // token is always the one currently stored and never sent once the session is cleared.\n const headers = useCallback((): Record<string, string> => {\n const current = readEditorSession();\n return current ? { authorization: `Bearer ${current.token}` } : {};\n }, []);\n\n const onExit = useCallback(async () => {\n clearEditorSession();\n window.location.assign(\"/\");\n }, []);\n\n return (\n <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref=\"/edit\" onExit={onExit} onEvent={reportEditorEvent}>\n {children}\n </RefinoProvider>\n );\n}\n";
22
+ export declare const COPY_EDITING_HOSTED = "\"use client\";\n\nimport type { CopyContent } from \"@getrefino/core\";\nimport { RefinoProvider } from \"@getrefino/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport { clearEditorSession, editorEndpoint, readEditorSession, refinoUpdates, reportEditorEvent } from \"./refino-client\";\nimport type { EditorSession } from \"./refino-client\";\n\ninterface CopyEditingProps {\n content: CopyContent;\n children: ReactNode;\n}\n\n/**\n * Hosted mode (Refino). Visitors get the plain site: the server never\n * decides edit mode, so pages can stay static. In the browser, an editor\n * session stored by /edit turns edit mode on; loads and saves go to Refino\n * with the session's bearer token, and Refino checks the site's\n * entitlement on every request.\n */\nexport function CopyEditing({ content, children }: CopyEditingProps) {\n const [session, setSession] = useState<EditorSession | null>(null);\n\n useEffect(() => {\n // Read the session only after mount so server-rendered HTML never differs from the visitor's.\n setSession(readEditorSession());\n }, []);\n\n // Read at request time (the provider keeps its options from the first render), so the\n // token is always the one currently stored and never sent once the session is cleared.\n const headers = useCallback((): Record<string, string> => {\n const current = readEditorSession();\n return current ? { authorization: `Bearer ${current.token}` } : {};\n }, []);\n\n const onExit = useCallback(async () => {\n clearEditorSession();\n window.location.assign(\"/\");\n }, []);\n\n return (\n <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref=\"/edit\" onExit={onExit} onEvent={reportEditorEvent} updates={refinoUpdates}>\n {children}\n </RefinoProvider>\n );\n}\n";
23
23
  /** The /edit page: starts the flow, finishes it on return, shows state. Works as a static export. */
24
24
  export declare const EDIT_PAGE_HOSTED = "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { beginAuthorization, clearEditorSession, completeAuthorization, readEditorSession, safeReturnTo } from \"./refino-client\";\n\ntype Phase = { kind: \"loading\" } | { kind: \"signed-in\" } | { kind: \"error\"; message: string };\n\n/**\n * Owner entry point for Refino-hosted editing. Visiting /edit sends the\n * owner to Refino to sign in and authorize this site; Refino sends them\n * back here with a one-time code, which is exchanged for an editor session\n * and scrubbed from the URL. Restyle freely; keep the behaviour.\n */\nexport default function EditPage() {\n const [phase, setPhase] = useState<Phase>({ kind: \"loading\" });\n\n useEffect(() => {\n let cancelled = false;\n const params = new URLSearchParams(window.location.search);\n if (params.has(\"code\") || params.has(\"state\")) {\n void completeAuthorization().then((result) => {\n if (cancelled) return;\n if (result.ok) window.location.assign(result.returnTo);\n else setPhase({ kind: \"error\", message: result.error });\n });\n } else if (readEditorSession()) {\n setPhase({ kind: \"signed-in\" });\n } else if (params.get(\"error\")) {\n setPhase({ kind: \"error\", message: params.get(\"error\") ?? \"Sign-in failed.\" });\n } else {\n void beginAuthorization(safeReturnTo(params.get(\"return_to\")));\n }\n return () => {\n cancelled = true;\n };\n }, []);\n\n return (\n <main style={{ maxWidth: 420, margin: \"10vh auto\", padding: 24, fontFamily: \"system-ui, sans-serif\" }}>\n {phase.kind === \"loading\" && <p>Signing you in with Refino\u2026</p>}\n {phase.kind === \"signed-in\" && (\n <>\n <h1>Edit mode is on</h1>\n <p>Go to the site, click any text, and press Save when you are done.</p>\n <p>\n <a href=\"/\">Open the site</a>\n </p>\n <button\n type=\"button\"\n onClick={() => {\n clearEditorSession();\n setPhase({ kind: \"loading\" });\n void beginAuthorization(\"/\");\n }}\n >\n Sign in as someone else\n </button>\n </>\n )}\n {phase.kind === \"error\" && (\n <>\n <h1>Could not start editing</h1>\n <p role=\"alert\">{phase.message}</p>\n <button type=\"button\" onClick={() => void beginAuthorization(\"/\")}>\n Try again\n </button>\n </>\n )}\n </main>\n );\n}\n";
25
25
  /** Next.js App Router route file for /edit: re-exports the generated client page. */
@@ -10,9 +10,9 @@
10
10
  * → <RefinoProvider endpoint={Refino copy API} headers={bearer}>
11
11
  */
12
12
  import { isInstallMethod } from "./install-method.js";
13
- import { TOOL_VERSION } from "./plan.js";
13
+ import { TOOL_VERSION } from "./constants.js";
14
14
  import { createTemplateFactory } from "./template-file.js";
15
- import { COPY_EDITING_HOSTED_RC1, RC1, REFINO_CLIENT_RC1, REFINO_SITE_RC1 } from "./templates-legacy.js";
15
+ import { COPY_EDITING_HOSTED_RC1, COPY_EDITING_HOSTED_RC4, RC1, RC4, REFINO_CLIENT_RC1, REFINO_CLIENT_RC4, REFINO_SITE_RC1 } from "./templates-legacy.js";
16
16
  export const DEFAULT_REFINO_APP_URL = "https://app.refino.dev";
17
17
  export const REFINO_SITE_ID_PATTERN = /^site_[a-f0-9]{32}$/;
18
18
  /** Public configuration; safe to ship to the browser and to commit. */
@@ -239,6 +239,32 @@ export function reportEditorEvent(event: EditorEventLike): void {
239
239
  // Never let reporting affect editing.
240
240
  }
241
241
  }
242
+
243
+ /**
244
+ * Updates to this site's Refino integration, offered in the editor
245
+ * toolbar. Asking is read-only. Nothing changes unless the owner presses
246
+ * "Update Refino": Refino then commits the new Refino version to this
247
+ * repository, and the site's normal deployment takes it from there. The
248
+ * editor checks every answer itself, so this stays a plain pipe.
249
+ */
250
+ export const refinoUpdates = {
251
+ check: (): Promise<unknown> => updateRequest("GET"),
252
+ apply: (version: string): Promise<unknown> => updateRequest("POST", JSON.stringify({ version })),
253
+ };
254
+
255
+ async function updateRequest(method: "GET" | "POST", body?: string): Promise<unknown> {
256
+ const session = readEditorSession();
257
+ if (!session) return null;
258
+ const headers: Record<string, string> = { accept: "application/json", authorization: \`Bearer \${session.token}\` };
259
+ if (body) headers["content-type"] = "application/json";
260
+ try {
261
+ const response = await fetch(\`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/update\`, { method, mode: "cors", credentials: "omit", headers, body });
262
+ return await response.json();
263
+ } catch {
264
+ // Unreachable or unreadable: the editor treats it as "nothing to offer", or as an unknown outcome.
265
+ return null;
266
+ }
267
+ }
242
268
  `;
243
269
  /** Provider wrapper: edit mode is decided in the browser from the stored session. */
244
270
  export const COPY_EDITING_HOSTED = `"use client";
@@ -248,7 +274,7 @@ import { RefinoProvider } from "@getrefino/react";
248
274
  import type { ReactNode } from "react";
249
275
  import { useCallback, useEffect, useState } from "react";
250
276
 
251
- import { clearEditorSession, editorEndpoint, readEditorSession, reportEditorEvent } from "./refino-client";
277
+ import { clearEditorSession, editorEndpoint, readEditorSession, refinoUpdates, reportEditorEvent } from "./refino-client";
252
278
  import type { EditorSession } from "./refino-client";
253
279
 
254
280
  interface CopyEditingProps {
@@ -284,7 +310,7 @@ export function CopyEditing({ content, children }: CopyEditingProps) {
284
310
  }, []);
285
311
 
286
312
  return (
287
- <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit} onEvent={reportEditorEvent}>
313
+ <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit} onEvent={reportEditorEvent} updates={refinoUpdates}>
288
314
  {children}
289
315
  </RefinoProvider>
290
316
  );
@@ -449,6 +475,9 @@ const recognizeSiteModule = (current, render) => {
449
475
  /** What 0.1.0-rc.1 wrote for the two hosted modules whose bodies have changed since. */
450
476
  const RC1_CLIENT = { version: RC1, content: REFINO_CLIENT_RC1 };
451
477
  const RC1_COPY_EDITING = { version: RC1, content: COPY_EDITING_HOSTED_RC1 };
478
+ /** What 0.1.0-rc.2 through 0.1.0-rc.4 wrote, before the toolbar could offer integration updates. */
479
+ const RC4_CLIENT = { version: RC4, content: REFINO_CLIENT_RC4 };
480
+ const RC4_COPY_EDITING = { version: RC4, content: COPY_EDITING_HOSTED_RC4 };
452
481
  /** Files for a Refino-connected site. No server code, no secrets, in any framework. */
453
482
  export function hostedFiles(plan, factory) {
454
483
  const refino = plan.refino;
@@ -463,8 +492,8 @@ export function hostedFiles(plan, factory) {
463
492
  hosting: analyticsHosting(plan.repository.hosting),
464
493
  packageVersion: TOOL_VERSION,
465
494
  }), { recognize: recognizeSiteModule }),
466
- make.machine(`${dir}/refino-client.ts`, REFINO_CLIENT, { previous: [RC1_CLIENT] }),
467
- make.machine(`${dir}/copy-editing.tsx`, COPY_EDITING_HOSTED, { previous: [RC1_COPY_EDITING] }),
495
+ make.machine(`${dir}/refino-client.ts`, REFINO_CLIENT, { previous: [RC4_CLIENT, RC1_CLIENT] }),
496
+ make.machine(`${dir}/copy-editing.tsx`, COPY_EDITING_HOSTED, { previous: [RC4_COPY_EDITING, RC1_COPY_EDITING] }),
468
497
  make.scaffold(`${dir}/edit-page.tsx`, EDIT_PAGE_HOSTED),
469
498
  ];
470
499
  const editPage = plan.files.find((file) => /(^|\/)edit(\/page)?\.\w+$/.test(file.path) && !file.path.startsWith(`${dir}/`))?.path;
@@ -15,11 +15,21 @@ export interface LegacySiteConfig {
15
15
  readonly siteId: string;
16
16
  readonly appUrl: string;
17
17
  }
18
- /** The last release that wrote the bodies below. */
18
+ /** The last release that wrote the rc.1 bodies below. */
19
19
  export declare const RC1 = "0.1.0-rc.1";
20
+ /**
21
+ * The last release that wrote the rc.4 bodies below (unchanged since rc.2).
22
+ * If rc.4 is ever published with the update-aware bodies instead, this label
23
+ * is still harmless: it only names the `from` version in upgrade reports.
24
+ */
25
+ export declare const RC4 = "0.1.0-rc.4";
20
26
  /** 0.1.0-rc.1 refino/refino-site.ts: the two public constants, before REFINO_INSTALL. */
21
27
  export declare const REFINO_SITE_RC1: (config: LegacySiteConfig) => string;
22
28
  /** 0.1.0-rc.1 refino/refino-client.ts: before reportEditorEvent and the install facts. */
23
29
  export declare const REFINO_CLIENT_RC1 = "/**\n * Browser-side Refino editor authorization for this site. No secrets here:\n * the site is a public client (OAuth 2.1 with PKCE). The editor token lives\n * in sessionStorage only (never localStorage, never a URL), is scoped to\n * this site, and Refino re-checks the account's entitlement on every load\n * and save. Never import this from server code.\n */\nimport { REFINO_APP_URL, REFINO_SITE_ID } from \"./refino-site\";\n\nconst PKCE_KEY = \"refino.pkce\";\nconst SESSION_KEY = \"refino.editor\";\nconst CALLBACK_PATH = \"/edit\";\n\nexport interface EditorSession {\n readonly token: string;\n /** Unix seconds. */\n readonly expiresAt: number;\n readonly siteId: string;\n}\n\ninterface PendingAuthorization {\n readonly verifier: string;\n readonly state: string;\n readonly returnTo: string;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction randomToken(bytes: number): string {\n const buffer = new Uint8Array(bytes);\n crypto.getRandomValues(buffer);\n return base64url(buffer);\n}\n\nasync function sha256(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text));\n return base64url(new Uint8Array(digest));\n}\n\nfunction storage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nfunction readJson<T>(key: string): T | null {\n const raw = storage()?.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Only a path on this site, never another origin. */\nexport function safeReturnTo(value: string | null | undefined): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\") || value.startsWith(\"/\\\\\") || value.length > 512) return \"/\";\n return value;\n}\n\nexport function editorEndpoint(): string {\n return `${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/copy`;\n}\n\nexport function callbackUrl(): string {\n return `${window.location.origin}${CALLBACK_PATH}`;\n}\n\n/** The current editor session, or null when there is none or it expired. */\nexport function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {\n const session = readJson<EditorSession>(SESSION_KEY);\n if (!session || typeof session.token !== \"string\" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== \"number\") return null;\n if (session.expiresAt <= nowSeconds) {\n storage()?.removeItem(SESSION_KEY);\n return null;\n }\n return session;\n}\n\nexport function clearEditorSession(): void {\n storage()?.removeItem(SESSION_KEY);\n storage()?.removeItem(PKCE_KEY);\n}\n\n/** Start the flow: remember the PKCE verifier and state, then go to Refino. */\nexport async function beginAuthorization(returnTo: string): Promise<void> {\n const verifier = randomToken(32);\n const state = randomToken(16);\n const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };\n storage()?.setItem(PKCE_KEY, JSON.stringify(pending));\n const params = new URLSearchParams({\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n code_challenge: await sha256(verifier),\n code_challenge_method: \"S256\",\n state,\n });\n window.location.assign(`${REFINO_APP_URL}/editor/authorize?${params.toString()}`);\n}\n\nexport type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };\n\n/**\n * Finish the flow on /edit?code&state: check the state, exchange the code\n * with the verifier this browser kept, store the session, and scrub the\n * code from the URL. The token is never placed in a URL.\n */\nexport async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {\n const params = new URLSearchParams(search);\n const code = params.get(\"code\");\n const state = params.get(\"state\");\n const pending = readJson<PendingAuthorization>(PKCE_KEY);\n storage()?.removeItem(PKCE_KEY);\n if (typeof window !== \"undefined\" && window.history.replaceState) window.history.replaceState(null, \"\", window.location.pathname);\n if (!code || !state) return { ok: false, error: \"Refino did not return an authorization code.\" };\n if (!pending || pending.state !== state) return { ok: false, error: \"Sign-in was interrupted (state mismatch). Start again.\" };\n\n let response: Response;\n try {\n response = await fetch(`${REFINO_APP_URL}/api/editor/token`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify({ grant_type: \"authorization_code\", code, code_verifier: pending.verifier, site_id: REFINO_SITE_ID, redirect_uri: callbackUrl() }),\n });\n } catch {\n return { ok: false, error: \"Could not reach Refino to finish signing in.\" };\n }\n const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;\n if (!response.ok || !body?.ok || typeof body.token !== \"string\" || typeof body.expiresAt !== \"number\" || body.siteId !== REFINO_SITE_ID) {\n return { ok: false, error: body?.error?.message ?? \"Refino did not accept the authorization.\" };\n }\n const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };\n storage()?.setItem(SESSION_KEY, JSON.stringify(session));\n return { ok: true, returnTo: pending.returnTo };\n}\n";
24
30
  /** 0.1.0-rc.1 refino/copy-editing.tsx: before onEvent={reportEditorEvent}. */
25
31
  export declare const COPY_EDITING_HOSTED_RC1 = "\"use client\";\n\nimport type { CopyContent } from \"@getrefino/core\";\nimport { RefinoProvider } from \"@getrefino/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport { clearEditorSession, editorEndpoint, readEditorSession } from \"./refino-client\";\nimport type { EditorSession } from \"./refino-client\";\n\ninterface CopyEditingProps {\n content: CopyContent;\n children: ReactNode;\n}\n\n/**\n * Hosted mode (Refino). Visitors get the plain site: the server never\n * decides edit mode, so pages can stay static. In the browser, an editor\n * session stored by /edit turns edit mode on; loads and saves go to Refino\n * with the session's bearer token, and Refino checks the site's\n * entitlement on every request.\n */\nexport function CopyEditing({ content, children }: CopyEditingProps) {\n const [session, setSession] = useState<EditorSession | null>(null);\n\n useEffect(() => {\n // Read the session only after mount so server-rendered HTML never differs from the visitor's.\n setSession(readEditorSession());\n }, []);\n\n // Read at request time (the provider keeps its options from the first render), so the\n // token is always the one currently stored and never sent once the session is cleared.\n const headers = useCallback((): Record<string, string> => {\n const current = readEditorSession();\n return current ? { authorization: `Bearer ${current.token}` } : {};\n }, []);\n\n const onExit = useCallback(async () => {\n clearEditorSession();\n window.location.assign(\"/\");\n }, []);\n\n return (\n <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref=\"/edit\" onExit={onExit}>\n {children}\n </RefinoProvider>\n );\n}\n";
32
+ /** 0.1.0-rc.4 refino/refino-client.ts: before refinoUpdates. */
33
+ export declare const REFINO_CLIENT_RC4 = "/**\n * Browser-side Refino editor authorization for this site. No secrets here:\n * the site is a public client (OAuth 2.1 with PKCE). The editor token lives\n * in sessionStorage only (never localStorage, never a URL), is scoped to\n * this site, and Refino re-checks the account's entitlement on every load\n * and save. Never import this from server code.\n */\nimport { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from \"./refino-site\";\n\nconst PKCE_KEY = \"refino.pkce\";\nconst SESSION_KEY = \"refino.editor\";\nconst CALLBACK_PATH = \"/edit\";\n\nexport interface EditorSession {\n readonly token: string;\n /** Unix seconds. */\n readonly expiresAt: number;\n readonly siteId: string;\n}\n\ninterface PendingAuthorization {\n readonly verifier: string;\n readonly state: string;\n readonly returnTo: string;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction randomToken(bytes: number): string {\n const buffer = new Uint8Array(bytes);\n crypto.getRandomValues(buffer);\n return base64url(buffer);\n}\n\nasync function sha256(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text));\n return base64url(new Uint8Array(digest));\n}\n\nfunction storage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nfunction readJson<T>(key: string): T | null {\n const raw = storage()?.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Only a path on this site, never another origin. */\nexport function safeReturnTo(value: string | null | undefined): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\") || value.startsWith(\"/\\\\\") || value.length > 512) return \"/\";\n return value;\n}\n\nexport function editorEndpoint(): string {\n return `${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/copy`;\n}\n\nexport function callbackUrl(): string {\n return `${window.location.origin}${CALLBACK_PATH}`;\n}\n\n/** The current editor session, or null when there is none or it expired. */\nexport function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {\n const session = readJson<EditorSession>(SESSION_KEY);\n if (!session || typeof session.token !== \"string\" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== \"number\") return null;\n if (session.expiresAt <= nowSeconds) {\n storage()?.removeItem(SESSION_KEY);\n return null;\n }\n return session;\n}\n\nexport function clearEditorSession(): void {\n storage()?.removeItem(SESSION_KEY);\n storage()?.removeItem(PKCE_KEY);\n}\n\n/** Start the flow: remember the PKCE verifier and state, then go to Refino. */\nexport async function beginAuthorization(returnTo: string): Promise<void> {\n const verifier = randomToken(32);\n const state = randomToken(16);\n const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };\n storage()?.setItem(PKCE_KEY, JSON.stringify(pending));\n const params = new URLSearchParams({\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n code_challenge: await sha256(verifier),\n code_challenge_method: \"S256\",\n state,\n });\n window.location.assign(`${REFINO_APP_URL}/editor/authorize?${params.toString()}`);\n}\n\nexport type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };\n\n/**\n * Finish the flow on /edit?code&state: check the state, exchange the code\n * with the verifier this browser kept, store the session, and scrub the\n * code from the URL. The token is never placed in a URL.\n */\nexport async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {\n const params = new URLSearchParams(search);\n const code = params.get(\"code\");\n const state = params.get(\"state\");\n const pending = readJson<PendingAuthorization>(PKCE_KEY);\n storage()?.removeItem(PKCE_KEY);\n if (typeof window !== \"undefined\" && window.history.replaceState) window.history.replaceState(null, \"\", window.location.pathname);\n if (!code || !state) return { ok: false, error: \"Refino did not return an authorization code.\" };\n if (!pending || pending.state !== state) return { ok: false, error: \"Sign-in was interrupted (state mismatch). Start again.\" };\n\n let response: Response;\n try {\n response = await fetch(`${REFINO_APP_URL}/api/editor/token`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify({\n grant_type: \"authorization_code\",\n code,\n code_verifier: pending.verifier,\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n // The public install facts from refino-site.ts, sent once per\n // sign-in. Refino records them the first time and ignores them\n // afterwards. Remove this line and everything still works.\n install: REFINO_INSTALL,\n }),\n });\n } catch {\n return { ok: false, error: \"Could not reach Refino to finish signing in.\" };\n }\n const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;\n if (!response.ok || !body?.ok || typeof body.token !== \"string\" || typeof body.expiresAt !== \"number\" || body.siteId !== REFINO_SITE_ID) {\n return { ok: false, error: body?.error?.message ?? \"Refino did not accept the authorization.\" };\n }\n const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };\n storage()?.setItem(SESSION_KEY, JSON.stringify(session));\n return { ok: true, returnTo: pending.returnTo };\n}\n\n/**\n * The three editor events this site reports, and the only ones it ever\n * sends. There is no analytics SDK on this site: no autocapture, no session\n * recording, no third-party script, no cookie. Each event is one small POST\n * to Refino carrying an event name and, for a revert, whether it was one\n * word or all of them \u2014 never your page, your copy, your visitors or your\n * repository. `edit_saved` is not in this list on purpose: a save is a\n * commit, and Refino records that on its own side where it cannot be faked\n * or lost.\n */\nconst REPORTABLE_EVENTS = [\"editor_opened\", \"edit_started\", \"edit_reverted\"] as const;\n\ntype ReportableEvent = (typeof REPORTABLE_EVENTS)[number];\n\ninterface EditorEventLike {\n readonly name: string;\n readonly properties: Readonly<Record<string, unknown>>;\n}\n\n/**\n * Fire and forget. A failure here is invisible and harmless: nothing in the\n * editor waits for it, retries it, or behaves differently when it fails.\n */\nexport function reportEditorEvent(event: EditorEventLike): void {\n const session = readEditorSession();\n if (!session) return;\n if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;\n const name = event.name as ReportableEvent;\n const scope = event.properties.scope;\n try {\n void fetch(`${REFINO_APP_URL}/api/editor/events`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", authorization: `Bearer ${session.token}` },\n body: JSON.stringify(scope === \"one\" || scope === \"all\" ? { event: name, scope } : { event: name }),\n }).catch(() => {});\n } catch {\n // Never let reporting affect editing.\n }\n}\n";
34
+ /** 0.1.0-rc.4 refino/copy-editing.tsx: before updates={refinoUpdates}. */
35
+ export declare const COPY_EDITING_HOSTED_RC4 = "\"use client\";\n\nimport type { CopyContent } from \"@getrefino/core\";\nimport { RefinoProvider } from \"@getrefino/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport { clearEditorSession, editorEndpoint, readEditorSession, reportEditorEvent } from \"./refino-client\";\nimport type { EditorSession } from \"./refino-client\";\n\ninterface CopyEditingProps {\n content: CopyContent;\n children: ReactNode;\n}\n\n/**\n * Hosted mode (Refino). Visitors get the plain site: the server never\n * decides edit mode, so pages can stay static. In the browser, an editor\n * session stored by /edit turns edit mode on; loads and saves go to Refino\n * with the session's bearer token, and Refino checks the site's\n * entitlement on every request.\n */\nexport function CopyEditing({ content, children }: CopyEditingProps) {\n const [session, setSession] = useState<EditorSession | null>(null);\n\n useEffect(() => {\n // Read the session only after mount so server-rendered HTML never differs from the visitor's.\n setSession(readEditorSession());\n }, []);\n\n // Read at request time (the provider keeps its options from the first render), so the\n // token is always the one currently stored and never sent once the session is cleared.\n const headers = useCallback((): Record<string, string> => {\n const current = readEditorSession();\n return current ? { authorization: `Bearer ${current.token}` } : {};\n }, []);\n\n const onExit = useCallback(async () => {\n clearEditorSession();\n window.location.assign(\"/\");\n }, []);\n\n return (\n <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref=\"/edit\" onExit={onExit} onEvent={reportEditorEvent}>\n {children}\n </RefinoProvider>\n );\n}\n";
@@ -11,8 +11,14 @@
11
11
  * renderings. `generated-templates.test.ts` fails until you do, because it
12
12
  * pins the hash of every generated file.
13
13
  */
14
- /** The last release that wrote the bodies below. */
14
+ /** The last release that wrote the rc.1 bodies below. */
15
15
  export const RC1 = "0.1.0-rc.1";
16
+ /**
17
+ * The last release that wrote the rc.4 bodies below (unchanged since rc.2).
18
+ * If rc.4 is ever published with the update-aware bodies instead, this label
19
+ * is still harmless: it only names the `from` version in upgrade reports.
20
+ */
21
+ export const RC4 = "0.1.0-rc.4";
16
22
  /** 0.1.0-rc.1 refino/refino-site.ts: the two public constants, before REFINO_INSTALL. */
17
23
  export const REFINO_SITE_RC1 = (config) => `/**
18
24
  * Public Refino configuration for this site. Neither value is a secret:
@@ -219,3 +225,252 @@ export function CopyEditing({ content, children }: CopyEditingProps) {
219
225
  );
220
226
  }
221
227
  `;
228
+ /** 0.1.0-rc.4 refino/refino-client.ts: before refinoUpdates. */
229
+ export const REFINO_CLIENT_RC4 = `/**
230
+ * Browser-side Refino editor authorization for this site. No secrets here:
231
+ * the site is a public client (OAuth 2.1 with PKCE). The editor token lives
232
+ * in sessionStorage only (never localStorage, never a URL), is scoped to
233
+ * this site, and Refino re-checks the account's entitlement on every load
234
+ * and save. Never import this from server code.
235
+ */
236
+ import { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from "./refino-site";
237
+
238
+ const PKCE_KEY = "refino.pkce";
239
+ const SESSION_KEY = "refino.editor";
240
+ const CALLBACK_PATH = "/edit";
241
+
242
+ export interface EditorSession {
243
+ readonly token: string;
244
+ /** Unix seconds. */
245
+ readonly expiresAt: number;
246
+ readonly siteId: string;
247
+ }
248
+
249
+ interface PendingAuthorization {
250
+ readonly verifier: string;
251
+ readonly state: string;
252
+ readonly returnTo: string;
253
+ }
254
+
255
+ function base64url(bytes: Uint8Array): string {
256
+ let binary = "";
257
+ for (const byte of bytes) binary += String.fromCharCode(byte);
258
+ return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
259
+ }
260
+
261
+ function randomToken(bytes: number): string {
262
+ const buffer = new Uint8Array(bytes);
263
+ crypto.getRandomValues(buffer);
264
+ return base64url(buffer);
265
+ }
266
+
267
+ async function sha256(text: string): Promise<string> {
268
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
269
+ return base64url(new Uint8Array(digest));
270
+ }
271
+
272
+ function storage(): Storage | null {
273
+ try {
274
+ return typeof window === "undefined" ? null : window.sessionStorage;
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ function readJson<T>(key: string): T | null {
281
+ const raw = storage()?.getItem(key);
282
+ if (!raw) return null;
283
+ try {
284
+ return JSON.parse(raw) as T;
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ /** Only a path on this site, never another origin. */
291
+ export function safeReturnTo(value: string | null | undefined): string {
292
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\\\") || value.length > 512) return "/";
293
+ return value;
294
+ }
295
+
296
+ export function editorEndpoint(): string {
297
+ return \`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/copy\`;
298
+ }
299
+
300
+ export function callbackUrl(): string {
301
+ return \`\${window.location.origin}\${CALLBACK_PATH}\`;
302
+ }
303
+
304
+ /** The current editor session, or null when there is none or it expired. */
305
+ export function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {
306
+ const session = readJson<EditorSession>(SESSION_KEY);
307
+ if (!session || typeof session.token !== "string" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== "number") return null;
308
+ if (session.expiresAt <= nowSeconds) {
309
+ storage()?.removeItem(SESSION_KEY);
310
+ return null;
311
+ }
312
+ return session;
313
+ }
314
+
315
+ export function clearEditorSession(): void {
316
+ storage()?.removeItem(SESSION_KEY);
317
+ storage()?.removeItem(PKCE_KEY);
318
+ }
319
+
320
+ /** Start the flow: remember the PKCE verifier and state, then go to Refino. */
321
+ export async function beginAuthorization(returnTo: string): Promise<void> {
322
+ const verifier = randomToken(32);
323
+ const state = randomToken(16);
324
+ const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };
325
+ storage()?.setItem(PKCE_KEY, JSON.stringify(pending));
326
+ const params = new URLSearchParams({
327
+ site_id: REFINO_SITE_ID,
328
+ redirect_uri: callbackUrl(),
329
+ code_challenge: await sha256(verifier),
330
+ code_challenge_method: "S256",
331
+ state,
332
+ });
333
+ window.location.assign(\`\${REFINO_APP_URL}/editor/authorize?\${params.toString()}\`);
334
+ }
335
+
336
+ export type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };
337
+
338
+ /**
339
+ * Finish the flow on /edit?code&state: check the state, exchange the code
340
+ * with the verifier this browser kept, store the session, and scrub the
341
+ * code from the URL. The token is never placed in a URL.
342
+ */
343
+ export async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {
344
+ const params = new URLSearchParams(search);
345
+ const code = params.get("code");
346
+ const state = params.get("state");
347
+ const pending = readJson<PendingAuthorization>(PKCE_KEY);
348
+ storage()?.removeItem(PKCE_KEY);
349
+ if (typeof window !== "undefined" && window.history.replaceState) window.history.replaceState(null, "", window.location.pathname);
350
+ if (!code || !state) return { ok: false, error: "Refino did not return an authorization code." };
351
+ if (!pending || pending.state !== state) return { ok: false, error: "Sign-in was interrupted (state mismatch). Start again." };
352
+
353
+ let response: Response;
354
+ try {
355
+ response = await fetch(\`\${REFINO_APP_URL}/api/editor/token\`, {
356
+ method: "POST",
357
+ mode: "cors",
358
+ credentials: "omit",
359
+ headers: { "content-type": "application/json", accept: "application/json" },
360
+ body: JSON.stringify({
361
+ grant_type: "authorization_code",
362
+ code,
363
+ code_verifier: pending.verifier,
364
+ site_id: REFINO_SITE_ID,
365
+ redirect_uri: callbackUrl(),
366
+ // The public install facts from refino-site.ts, sent once per
367
+ // sign-in. Refino records them the first time and ignores them
368
+ // afterwards. Remove this line and everything still works.
369
+ install: REFINO_INSTALL,
370
+ }),
371
+ });
372
+ } catch {
373
+ return { ok: false, error: "Could not reach Refino to finish signing in." };
374
+ }
375
+ const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;
376
+ if (!response.ok || !body?.ok || typeof body.token !== "string" || typeof body.expiresAt !== "number" || body.siteId !== REFINO_SITE_ID) {
377
+ return { ok: false, error: body?.error?.message ?? "Refino did not accept the authorization." };
378
+ }
379
+ const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };
380
+ storage()?.setItem(SESSION_KEY, JSON.stringify(session));
381
+ return { ok: true, returnTo: pending.returnTo };
382
+ }
383
+
384
+ /**
385
+ * The three editor events this site reports, and the only ones it ever
386
+ * sends. There is no analytics SDK on this site: no autocapture, no session
387
+ * recording, no third-party script, no cookie. Each event is one small POST
388
+ * to Refino carrying an event name and, for a revert, whether it was one
389
+ * word or all of them — never your page, your copy, your visitors or your
390
+ * repository. \`edit_saved\` is not in this list on purpose: a save is a
391
+ * commit, and Refino records that on its own side where it cannot be faked
392
+ * or lost.
393
+ */
394
+ const REPORTABLE_EVENTS = ["editor_opened", "edit_started", "edit_reverted"] as const;
395
+
396
+ type ReportableEvent = (typeof REPORTABLE_EVENTS)[number];
397
+
398
+ interface EditorEventLike {
399
+ readonly name: string;
400
+ readonly properties: Readonly<Record<string, unknown>>;
401
+ }
402
+
403
+ /**
404
+ * Fire and forget. A failure here is invisible and harmless: nothing in the
405
+ * editor waits for it, retries it, or behaves differently when it fails.
406
+ */
407
+ export function reportEditorEvent(event: EditorEventLike): void {
408
+ const session = readEditorSession();
409
+ if (!session) return;
410
+ if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;
411
+ const name = event.name as ReportableEvent;
412
+ const scope = event.properties.scope;
413
+ try {
414
+ void fetch(\`\${REFINO_APP_URL}/api/editor/events\`, {
415
+ method: "POST",
416
+ mode: "cors",
417
+ credentials: "omit",
418
+ keepalive: true,
419
+ headers: { "content-type": "application/json", authorization: \`Bearer \${session.token}\` },
420
+ body: JSON.stringify(scope === "one" || scope === "all" ? { event: name, scope } : { event: name }),
421
+ }).catch(() => {});
422
+ } catch {
423
+ // Never let reporting affect editing.
424
+ }
425
+ }
426
+ `;
427
+ /** 0.1.0-rc.4 refino/copy-editing.tsx: before updates={refinoUpdates}. */
428
+ export const COPY_EDITING_HOSTED_RC4 = `"use client";
429
+
430
+ import type { CopyContent } from "@getrefino/core";
431
+ import { RefinoProvider } from "@getrefino/react";
432
+ import type { ReactNode } from "react";
433
+ import { useCallback, useEffect, useState } from "react";
434
+
435
+ import { clearEditorSession, editorEndpoint, readEditorSession, reportEditorEvent } from "./refino-client";
436
+ import type { EditorSession } from "./refino-client";
437
+
438
+ interface CopyEditingProps {
439
+ content: CopyContent;
440
+ children: ReactNode;
441
+ }
442
+
443
+ /**
444
+ * Hosted mode (Refino). Visitors get the plain site: the server never
445
+ * decides edit mode, so pages can stay static. In the browser, an editor
446
+ * session stored by /edit turns edit mode on; loads and saves go to Refino
447
+ * with the session's bearer token, and Refino checks the site's
448
+ * entitlement on every request.
449
+ */
450
+ export function CopyEditing({ content, children }: CopyEditingProps) {
451
+ const [session, setSession] = useState<EditorSession | null>(null);
452
+
453
+ useEffect(() => {
454
+ // Read the session only after mount so server-rendered HTML never differs from the visitor's.
455
+ setSession(readEditorSession());
456
+ }, []);
457
+
458
+ // Read at request time (the provider keeps its options from the first render), so the
459
+ // token is always the one currently stored and never sent once the session is cleared.
460
+ const headers = useCallback((): Record<string, string> => {
461
+ const current = readEditorSession();
462
+ return current ? { authorization: \`Bearer \${current.token}\` } : {};
463
+ }, []);
464
+
465
+ const onExit = useCallback(async () => {
466
+ clearEditorSession();
467
+ window.location.assign("/");
468
+ }, []);
469
+
470
+ return (
471
+ <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit} onEvent={reportEditorEvent}>
472
+ {children}
473
+ </RefinoProvider>
474
+ );
475
+ }
476
+ `;
package/dist/templates.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * is invited to restyle -- and, when its body has changed since a released
11
11
  * version, the byte-exact body that version wrote.
12
12
  */
13
- import { BOILERPLATE_DIR } from "./plan.js";
13
+ import { BOILERPLATE_DIR } from "./constants.js";
14
14
  import { createTemplateFactory } from "./template-file.js";
15
15
  import { hostedFiles } from "./templates-hosted.js";
16
16
  const BOILERPLATE_DIR_NAME = BOILERPLATE_DIR;