@getrefino/onboarding 0.1.0-rc.1

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.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Boilerplate for a site connected to Refino (hosted mode). Everything runs
3
+ * in the browser: the site holds two public constants and no secrets, no
4
+ * copy endpoint, no session endpoint and no repository credential. Refino
5
+ * authenticates the owner, authorizes the site, and loads and saves the
6
+ * copy file in the repository.
7
+ *
8
+ * /edit → Refino /editor/authorize (PKCE) → back to /edit?code&state
9
+ * → POST Refino /api/editor/token → 12 h editor token in sessionStorage
10
+ * → <RefinoProvider endpoint={Refino copy API} headers={bearer}>
11
+ */
12
+ import type { MigrationPlan } from "./types.js";
13
+ import type { TemplateFile } from "./templates.js";
14
+ export declare const DEFAULT_REFINO_APP_URL = "https://app.refino.dev";
15
+ export interface RefinoSiteConfig {
16
+ readonly siteId: string;
17
+ readonly appUrl: string;
18
+ }
19
+ export declare const REFINO_SITE_ID_PATTERN: RegExp;
20
+ /** Public configuration; safe to ship to the browser and to commit. */
21
+ export declare const REFINO_SITE: (config: RefinoSiteConfig) => string;
22
+ /** Browser-only client: PKCE, the authorization round trip and the session in sessionStorage. */
23
+ 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_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
+ /** Provider wrapper: edit mode is decided in the browser from the stored session. */
25
+ 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 } 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";
26
+ /** The /edit page: starts the flow, finishes it on return, shows state. Works as a static export. */
27
+ 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";
28
+ /** Next.js App Router route file for /edit: re-exports the generated client page. */
29
+ export declare const EDIT_ROUTE_NEXT_HOSTED: (pageImport: string) => string;
30
+ /** Next.js Pages Router page for /edit. */
31
+ export declare const EDIT_PAGE_NEXT_PAGES_HOSTED: (pageImport: string) => string;
32
+ export declare const EDIT_LAYOUT_HOSTED = "import type { Metadata } from \"next\";\nimport type { ReactNode } from \"react\";\n\n/** /edit is the owner's entry point, not a public page; the page itself is a client component and cannot export metadata. */\nexport const metadata: Metadata = { title: \"Edit this site\", robots: { index: false, follow: false } };\n\nexport default function EditLayout({ children }: { children: ReactNode }) {\n return children;\n}\n";
33
+ /** Files for a Refino-connected site. No server code, no secrets, in any framework. */
34
+ export declare function hostedFiles(plan: MigrationPlan): TemplateFile[];
@@ -0,0 +1,332 @@
1
+ export const DEFAULT_REFINO_APP_URL = "https://app.refino.dev";
2
+ export const REFINO_SITE_ID_PATTERN = /^site_[a-f0-9]{32}$/;
3
+ /** Public configuration; safe to ship to the browser and to commit. */
4
+ export const REFINO_SITE = (config) => `/**
5
+ * Public Refino configuration for this site. Neither value is a secret:
6
+ * the site id appears in editor URLs and the app URL is where owners sign
7
+ * in. Generated by Refino; change it only when the site is
8
+ * reconnected to Refino.
9
+ */
10
+ export const REFINO_SITE_ID = ${JSON.stringify(config.siteId)};
11
+ export const REFINO_APP_URL = ${JSON.stringify(config.appUrl)};
12
+ `;
13
+ /** Browser-only client: PKCE, the authorization round trip and the session in sessionStorage. */
14
+ export const REFINO_CLIENT = `/**
15
+ * Browser-side Refino editor authorization for this site. No secrets here:
16
+ * the site is a public client (OAuth 2.1 with PKCE). The editor token lives
17
+ * in sessionStorage only (never localStorage, never a URL), is scoped to
18
+ * this site, and Refino re-checks the account's entitlement on every load
19
+ * and save. Never import this from server code.
20
+ */
21
+ import { REFINO_APP_URL, REFINO_SITE_ID } from "./refino-site";
22
+
23
+ const PKCE_KEY = "refino.pkce";
24
+ const SESSION_KEY = "refino.editor";
25
+ const CALLBACK_PATH = "/edit";
26
+
27
+ export interface EditorSession {
28
+ readonly token: string;
29
+ /** Unix seconds. */
30
+ readonly expiresAt: number;
31
+ readonly siteId: string;
32
+ }
33
+
34
+ interface PendingAuthorization {
35
+ readonly verifier: string;
36
+ readonly state: string;
37
+ readonly returnTo: string;
38
+ }
39
+
40
+ function base64url(bytes: Uint8Array): string {
41
+ let binary = "";
42
+ for (const byte of bytes) binary += String.fromCharCode(byte);
43
+ return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
44
+ }
45
+
46
+ function randomToken(bytes: number): string {
47
+ const buffer = new Uint8Array(bytes);
48
+ crypto.getRandomValues(buffer);
49
+ return base64url(buffer);
50
+ }
51
+
52
+ async function sha256(text: string): Promise<string> {
53
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
54
+ return base64url(new Uint8Array(digest));
55
+ }
56
+
57
+ function storage(): Storage | null {
58
+ try {
59
+ return typeof window === "undefined" ? null : window.sessionStorage;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function readJson<T>(key: string): T | null {
66
+ const raw = storage()?.getItem(key);
67
+ if (!raw) return null;
68
+ try {
69
+ return JSON.parse(raw) as T;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** Only a path on this site, never another origin. */
76
+ export function safeReturnTo(value: string | null | undefined): string {
77
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\\\") || value.length > 512) return "/";
78
+ return value;
79
+ }
80
+
81
+ export function editorEndpoint(): string {
82
+ return \`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/copy\`;
83
+ }
84
+
85
+ export function callbackUrl(): string {
86
+ return \`\${window.location.origin}\${CALLBACK_PATH}\`;
87
+ }
88
+
89
+ /** The current editor session, or null when there is none or it expired. */
90
+ export function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {
91
+ const session = readJson<EditorSession>(SESSION_KEY);
92
+ if (!session || typeof session.token !== "string" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== "number") return null;
93
+ if (session.expiresAt <= nowSeconds) {
94
+ storage()?.removeItem(SESSION_KEY);
95
+ return null;
96
+ }
97
+ return session;
98
+ }
99
+
100
+ export function clearEditorSession(): void {
101
+ storage()?.removeItem(SESSION_KEY);
102
+ storage()?.removeItem(PKCE_KEY);
103
+ }
104
+
105
+ /** Start the flow: remember the PKCE verifier and state, then go to Refino. */
106
+ export async function beginAuthorization(returnTo: string): Promise<void> {
107
+ const verifier = randomToken(32);
108
+ const state = randomToken(16);
109
+ const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };
110
+ storage()?.setItem(PKCE_KEY, JSON.stringify(pending));
111
+ const params = new URLSearchParams({
112
+ site_id: REFINO_SITE_ID,
113
+ redirect_uri: callbackUrl(),
114
+ code_challenge: await sha256(verifier),
115
+ code_challenge_method: "S256",
116
+ state,
117
+ });
118
+ window.location.assign(\`\${REFINO_APP_URL}/editor/authorize?\${params.toString()}\`);
119
+ }
120
+
121
+ export type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };
122
+
123
+ /**
124
+ * Finish the flow on /edit?code&state: check the state, exchange the code
125
+ * with the verifier this browser kept, store the session, and scrub the
126
+ * code from the URL. The token is never placed in a URL.
127
+ */
128
+ export async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {
129
+ const params = new URLSearchParams(search);
130
+ const code = params.get("code");
131
+ const state = params.get("state");
132
+ const pending = readJson<PendingAuthorization>(PKCE_KEY);
133
+ storage()?.removeItem(PKCE_KEY);
134
+ if (typeof window !== "undefined" && window.history.replaceState) window.history.replaceState(null, "", window.location.pathname);
135
+ if (!code || !state) return { ok: false, error: "Refino did not return an authorization code." };
136
+ if (!pending || pending.state !== state) return { ok: false, error: "Sign-in was interrupted (state mismatch). Start again." };
137
+
138
+ let response: Response;
139
+ try {
140
+ response = await fetch(\`\${REFINO_APP_URL}/api/editor/token\`, {
141
+ method: "POST",
142
+ mode: "cors",
143
+ credentials: "omit",
144
+ headers: { "content-type": "application/json", accept: "application/json" },
145
+ body: JSON.stringify({ grant_type: "authorization_code", code, code_verifier: pending.verifier, site_id: REFINO_SITE_ID, redirect_uri: callbackUrl() }),
146
+ });
147
+ } catch {
148
+ return { ok: false, error: "Could not reach Refino to finish signing in." };
149
+ }
150
+ const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;
151
+ if (!response.ok || !body?.ok || typeof body.token !== "string" || typeof body.expiresAt !== "number" || body.siteId !== REFINO_SITE_ID) {
152
+ return { ok: false, error: body?.error?.message ?? "Refino did not accept the authorization." };
153
+ }
154
+ const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };
155
+ storage()?.setItem(SESSION_KEY, JSON.stringify(session));
156
+ return { ok: true, returnTo: pending.returnTo };
157
+ }
158
+ `;
159
+ /** Provider wrapper: edit mode is decided in the browser from the stored session. */
160
+ export const COPY_EDITING_HOSTED = `"use client";
161
+
162
+ import type { CopyContent } from "@getrefino/core";
163
+ import { RefinoProvider } from "@getrefino/react";
164
+ import type { ReactNode } from "react";
165
+ import { useCallback, useEffect, useState } from "react";
166
+
167
+ import { clearEditorSession, editorEndpoint, readEditorSession } from "./refino-client";
168
+ import type { EditorSession } from "./refino-client";
169
+
170
+ interface CopyEditingProps {
171
+ content: CopyContent;
172
+ children: ReactNode;
173
+ }
174
+
175
+ /**
176
+ * Hosted mode (Refino). Visitors get the plain site: the server never
177
+ * decides edit mode, so pages can stay static. In the browser, an editor
178
+ * session stored by /edit turns edit mode on; loads and saves go to Refino
179
+ * with the session's bearer token, and Refino checks the site's
180
+ * entitlement on every request.
181
+ */
182
+ export function CopyEditing({ content, children }: CopyEditingProps) {
183
+ const [session, setSession] = useState<EditorSession | null>(null);
184
+
185
+ useEffect(() => {
186
+ // Read the session only after mount so server-rendered HTML never differs from the visitor's.
187
+ setSession(readEditorSession());
188
+ }, []);
189
+
190
+ // Read at request time (the provider keeps its options from the first render), so the
191
+ // token is always the one currently stored and never sent once the session is cleared.
192
+ const headers = useCallback((): Record<string, string> => {
193
+ const current = readEditorSession();
194
+ return current ? { authorization: \`Bearer \${current.token}\` } : {};
195
+ }, []);
196
+
197
+ const onExit = useCallback(async () => {
198
+ clearEditorSession();
199
+ window.location.assign("/");
200
+ }, []);
201
+
202
+ return (
203
+ <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit}>
204
+ {children}
205
+ </RefinoProvider>
206
+ );
207
+ }
208
+ `;
209
+ /** The /edit page: starts the flow, finishes it on return, shows state. Works as a static export. */
210
+ export const EDIT_PAGE_HOSTED = `"use client";
211
+
212
+ import { useEffect, useState } from "react";
213
+
214
+ import { beginAuthorization, clearEditorSession, completeAuthorization, readEditorSession, safeReturnTo } from "./refino-client";
215
+
216
+ type Phase = { kind: "loading" } | { kind: "signed-in" } | { kind: "error"; message: string };
217
+
218
+ /**
219
+ * Owner entry point for Refino-hosted editing. Visiting /edit sends the
220
+ * owner to Refino to sign in and authorize this site; Refino sends them
221
+ * back here with a one-time code, which is exchanged for an editor session
222
+ * and scrubbed from the URL. Restyle freely; keep the behaviour.
223
+ */
224
+ export default function EditPage() {
225
+ const [phase, setPhase] = useState<Phase>({ kind: "loading" });
226
+
227
+ useEffect(() => {
228
+ let cancelled = false;
229
+ const params = new URLSearchParams(window.location.search);
230
+ if (params.has("code") || params.has("state")) {
231
+ void completeAuthorization().then((result) => {
232
+ if (cancelled) return;
233
+ if (result.ok) window.location.assign(result.returnTo);
234
+ else setPhase({ kind: "error", message: result.error });
235
+ });
236
+ } else if (readEditorSession()) {
237
+ setPhase({ kind: "signed-in" });
238
+ } else if (params.get("error")) {
239
+ setPhase({ kind: "error", message: params.get("error") ?? "Sign-in failed." });
240
+ } else {
241
+ void beginAuthorization(safeReturnTo(params.get("return_to")));
242
+ }
243
+ return () => {
244
+ cancelled = true;
245
+ };
246
+ }, []);
247
+
248
+ return (
249
+ <main style={{ maxWidth: 420, margin: "10vh auto", padding: 24, fontFamily: "system-ui, sans-serif" }}>
250
+ {phase.kind === "loading" && <p>Signing you in with Refino…</p>}
251
+ {phase.kind === "signed-in" && (
252
+ <>
253
+ <h1>Edit mode is on</h1>
254
+ <p>Go to the site, click any text, and press Save when you are done.</p>
255
+ <p>
256
+ <a href="/">Open the site</a>
257
+ </p>
258
+ <button
259
+ type="button"
260
+ onClick={() => {
261
+ clearEditorSession();
262
+ setPhase({ kind: "loading" });
263
+ void beginAuthorization("/");
264
+ }}
265
+ >
266
+ Sign in as someone else
267
+ </button>
268
+ </>
269
+ )}
270
+ {phase.kind === "error" && (
271
+ <>
272
+ <h1>Could not start editing</h1>
273
+ <p role="alert">{phase.message}</p>
274
+ <button type="button" onClick={() => void beginAuthorization("/")}>
275
+ Try again
276
+ </button>
277
+ </>
278
+ )}
279
+ </main>
280
+ );
281
+ }
282
+ `;
283
+ /** Next.js App Router route file for /edit: re-exports the generated client page. */
284
+ export const EDIT_ROUTE_NEXT_HOSTED = (pageImport) => `export { default } from "${pageImport}";
285
+ `;
286
+ /** Next.js Pages Router page for /edit. */
287
+ export const EDIT_PAGE_NEXT_PAGES_HOSTED = (pageImport) => `export { default } from "${pageImport}";
288
+ `;
289
+ export const EDIT_LAYOUT_HOSTED = `import type { Metadata } from "next";
290
+ import type { ReactNode } from "react";
291
+
292
+ /** /edit is the owner's entry point, not a public page; the page itself is a client component and cannot export metadata. */
293
+ export const metadata: Metadata = { title: "Edit this site", robots: { index: false, follow: false } };
294
+
295
+ export default function EditLayout({ children }: { children: ReactNode }) {
296
+ return children;
297
+ }
298
+ `;
299
+ function relativeImport(fromFile, toFile) {
300
+ const fromParts = fromFile.split("/").slice(0, -1);
301
+ const toParts = toFile.split("/");
302
+ while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) {
303
+ fromParts.shift();
304
+ toParts.shift();
305
+ }
306
+ const up = fromParts.length === 0 ? "." : fromParts.map(() => "..").join("/");
307
+ return `${up}/${toParts.join("/")}`.replace(/\.(tsx?|jsx?)$/, "");
308
+ }
309
+ /** Files for a Refino-connected site. No server code, no secrets, in any framework. */
310
+ export function hostedFiles(plan) {
311
+ const refino = plan.refino;
312
+ if (!refino)
313
+ return [];
314
+ const dir = plan.integration.boilerplateDir;
315
+ const files = [
316
+ { path: `${dir}/refino-site.ts`, content: REFINO_SITE(refino) },
317
+ { path: `${dir}/refino-client.ts`, content: REFINO_CLIENT },
318
+ { path: `${dir}/copy-editing.tsx`, content: COPY_EDITING_HOSTED },
319
+ { path: `${dir}/edit-page.tsx`, content: EDIT_PAGE_HOSTED },
320
+ ];
321
+ const editPage = plan.files.find((file) => /(^|\/)edit(\/page)?\.\w+$/.test(file.path) && !file.path.startsWith(`${dir}/`))?.path;
322
+ if (plan.repository.router === "next-app") {
323
+ const routesDir = editPage?.replace(/\/edit\/page\.\w+$/, "") ?? "app";
324
+ files.push({ path: `${routesDir}/edit/layout.tsx`, content: EDIT_LAYOUT_HOSTED });
325
+ files.push({ path: `${routesDir}/edit/page.tsx`, content: EDIT_ROUTE_NEXT_HOSTED(relativeImport(`${routesDir}/edit/page.tsx`, `${dir}/edit-page.tsx`)) });
326
+ }
327
+ else if (plan.repository.router === "next-pages") {
328
+ const routesDir = editPage?.replace(/\/edit\.\w+$/, "") ?? "pages";
329
+ files.push({ path: `${routesDir}/edit.tsx`, content: EDIT_PAGE_NEXT_PAGES_HOSTED(relativeImport(`${routesDir}/edit.tsx`, `${dir}/edit-page.tsx`)) });
330
+ }
331
+ return files;
332
+ }
@@ -0,0 +1,8 @@
1
+ import type { MigrationPlan } from "./types.js";
2
+ export interface TemplateFile {
3
+ readonly path: string;
4
+ readonly content: string;
5
+ }
6
+ export declare const ENV_EXAMPLE_BLOCK = "\n# --- Refino (server-side only; never expose with NEXT_PUBLIC_/VITE_) ---\n# Edit-mode login. Both required in production; development falls back to password \"edit\".\nEDITOR_PASSWORD=\nEDITOR_SESSION_SECRET=\n# Persistence: \"local\" writes the copy file on disk (development), \"github\" commits it.\nCOPY_ADAPTER=local\n# GitHub persistence (COPY_ADAPTER=github): fine-grained token, Contents read/write, one repo.\nCOPY_GITHUB_TOKEN=\nCOPY_GITHUB_REPO=owner/name\nCOPY_GITHUB_BRANCH=main\nCOPY_FILE_PATH=\n";
7
+ /** Files the apply step may create for a plan, in TS or JS as the project requires. */
8
+ export declare function boilerplateFiles(plan: MigrationPlan): TemplateFile[];