@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,671 @@
1
+ /**
2
+ * Boilerplate written by `refino init --apply`. Each file is isolated
3
+ * under `refino/` (or is a new route file) and is only written when it
4
+ * does not already exist. Authored in TypeScript; JavaScript projects get a
5
+ * transpiled copy.
6
+ */
7
+ import ts from "typescript";
8
+ import { BOILERPLATE_DIR } from "./plan.js";
9
+ import { hostedFiles } from "./templates-hosted.js";
10
+ const BOILERPLATE_DIR_NAME = BOILERPLATE_DIR;
11
+ function relativeImport(fromFile, toFile) {
12
+ const fromParts = fromFile.split("/").slice(0, -1);
13
+ const toParts = toFile.split("/");
14
+ while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) {
15
+ fromParts.shift();
16
+ toParts.shift();
17
+ }
18
+ const up = fromParts.length === 0 ? "." : fromParts.map(() => "..").join("/");
19
+ return `${up}/${toParts.join("/")}`.replace(/\.(tsx?|jsx?)$/, "");
20
+ }
21
+ const EDITOR_AUTH = `/**
22
+ * Single-owner edit-mode authentication for Refino. Server-side only.
23
+ * One password (EDITOR_PASSWORD) unlocks a signed, expiring cookie whose
24
+ * signature uses EDITOR_SESSION_SECRET. Replace isEditorAuthenticated in
25
+ * editor-session with your own owner check if the site already has auth.
26
+ */
27
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
28
+
29
+ export const SESSION_COOKIE = "refino_edit_session";
30
+ export const SESSION_TTL_SECONDS = 12 * 60 * 60;
31
+
32
+ export interface EditorAuthConfig {
33
+ readonly password: string;
34
+ readonly secret: string;
35
+ }
36
+
37
+ const DEV_FALLBACK_PASSWORD = "edit";
38
+ let warnedAboutFallback = false;
39
+
40
+ function devFallbackSecret(): string {
41
+ return createHash("sha256").update(\`refino-dev-secret:\${process.cwd()}\`).digest("hex");
42
+ }
43
+
44
+ /** Null means editing is disabled (production without both variables). */
45
+ export function readEditorAuthConfig(
46
+ env: Readonly<Record<string, string | undefined>> = process.env,
47
+ ): EditorAuthConfig | null {
48
+ const password = env.EDITOR_PASSWORD;
49
+ const secret = env.EDITOR_SESSION_SECRET;
50
+ if (password && secret) return { password, secret };
51
+ if (env.NODE_ENV === "development") {
52
+ if (!warnedAboutFallback) {
53
+ warnedAboutFallback = true;
54
+ console.warn(\`[refino] EDITOR_PASSWORD/EDITOR_SESSION_SECRET not set; using the development password "\${DEV_FALLBACK_PASSWORD}".\`);
55
+ }
56
+ return { password: password ?? DEV_FALLBACK_PASSWORD, secret: secret ?? devFallbackSecret() };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function digest(value: string): Buffer {
62
+ return createHash("sha256").update(value, "utf8").digest();
63
+ }
64
+
65
+ export function verifyPassword(config: EditorAuthConfig, candidate: string): boolean {
66
+ return timingSafeEqual(digest(config.password), digest(candidate));
67
+ }
68
+
69
+ function sign(config: EditorAuthConfig, payload: string): string {
70
+ return createHmac("sha256", config.secret).update(payload, "utf8").digest("base64url");
71
+ }
72
+
73
+ export function createSessionToken(config: EditorAuthConfig, now: number = Date.now()): string {
74
+ const payload = String(Math.floor(now / 1000) + SESSION_TTL_SECONDS);
75
+ return \`\${payload}.\${sign(config, payload)}\`;
76
+ }
77
+
78
+ export function verifySessionToken(config: EditorAuthConfig, token: string | undefined, now: number = Date.now()): boolean {
79
+ if (!token) return false;
80
+ const separator = token.indexOf(".");
81
+ if (separator <= 0) return false;
82
+ const payload = token.slice(0, separator);
83
+ const signature = token.slice(separator + 1);
84
+ if (!/^\\d{1,12}$/.test(payload) || signature.length === 0) return false;
85
+ const a = Buffer.from(signature, "utf8");
86
+ const b = Buffer.from(sign(config, payload), "utf8");
87
+ if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
88
+ return Number(payload) * 1000 > now;
89
+ }
90
+
91
+ /** Parse a Cookie header and return the session token, if any. */
92
+ export function sessionTokenFromCookieHeader(cookieHeader: string | null | undefined): string | undefined {
93
+ if (!cookieHeader) return undefined;
94
+ for (const part of cookieHeader.split(";")) {
95
+ const [name, ...rest] = part.trim().split("=");
96
+ if (name === SESSION_COOKIE) return decodeURIComponent(rest.join("="));
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ export function serializeSessionCookie(value: string, options: { secure: boolean; maxAge?: number }): string {
102
+ const parts = [\`\${SESSION_COOKIE}=\${encodeURIComponent(value)}\`, "Path=/", "HttpOnly", "SameSite=Lax", \`Max-Age=\${options.maxAge ?? SESSION_TTL_SECONDS}\`];
103
+ if (options.secure) parts.push("Secure");
104
+ return parts.join("; ");
105
+ }
106
+
107
+ /** Reject cross-site requests to state-changing endpoints. */
108
+ export function isSameOriginRequest(request: { headers: { get(name: string): string | null } }): boolean {
109
+ const site = request.headers.get("sec-fetch-site");
110
+ if (site && site !== "same-origin" && site !== "none") return false;
111
+ const origin = request.headers.get("origin");
112
+ if (origin) {
113
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
114
+ try {
115
+ if (host && new URL(origin).host !== host) return false;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+ return true;
121
+ }
122
+ `;
123
+ const CONTENT_ADAPTER = (contentFile) => `/**
124
+ * Where saved copy goes. Server-side only; never import from client code.
125
+ * COPY_ADAPTER=local writes ${contentFile} on disk (development default)
126
+ * COPY_ADAPTER=github commits the file through the GitHub Contents API
127
+ */
128
+ import path from "node:path";
129
+
130
+ import type { ContentAdapter } from "@getrefino/core";
131
+ import { ContentError, createContentApi } from "@getrefino/core";
132
+ import { createLocalFileContentAdapter } from "@getrefino/core/local-file";
133
+ import { createGitHubContentAdapter, githubOptionsFromEnv } from "@getrefino/github";
134
+
135
+ export const LOCAL_COPY_FILE = path.join(process.cwd(), ${JSON.stringify(contentFile)});
136
+
137
+ export function createContentAdapter(env: Readonly<Record<string, string | undefined>> = process.env): ContentAdapter {
138
+ const kind = env.COPY_ADAPTER ?? (env.NODE_ENV === "production" ? undefined : "local");
139
+ switch (kind) {
140
+ case "local":
141
+ return createLocalFileContentAdapter({ filePath: LOCAL_COPY_FILE });
142
+ case "github":
143
+ return createGitHubContentAdapter(githubOptionsFromEnv(env));
144
+ default:
145
+ throw new ContentError("NOT_CONFIGURED", 'Set COPY_ADAPTER to "local" or "github" to enable saving.');
146
+ }
147
+ }
148
+
149
+ let adapter: ContentAdapter | null = null;
150
+
151
+ /** Resolved on first use so a misconfiguration becomes a 503, not a crash at import. */
152
+ export const contentAdapter: ContentAdapter = {
153
+ get name() {
154
+ return (adapter ??= createContentAdapter()).name;
155
+ },
156
+ load: () => (adapter ??= createContentAdapter()).load(),
157
+ save: (request) => (adapter ??= createContentAdapter()).save(request),
158
+ };
159
+
160
+ export const contentApi = createContentApi(contentAdapter, {
161
+ onError: (error) => console.error("[refino]", error),
162
+ });
163
+
164
+ /**
165
+ * For runtimes that hand the environment to each request (Cloudflare Pages
166
+ * Functions, Netlify, Vercel): an API whose adapter is built lazily from that
167
+ * environment, so a misconfiguration is a 503 from the API, never a crash.
168
+ */
169
+ export function createContentApiFor(env: Readonly<Record<string, string | undefined>>) {
170
+ let resolved: ContentAdapter | null = null;
171
+ const lazy: ContentAdapter = {
172
+ get name() {
173
+ return (resolved ??= createContentAdapter(env)).name;
174
+ },
175
+ load: () => (resolved ??= createContentAdapter(env)).load(),
176
+ save: (request) => (resolved ??= createContentAdapter(env)).save(request),
177
+ };
178
+ return createContentApi(lazy, { onError: (error) => console.error("[refino]", error) });
179
+ }
180
+ `;
181
+ const COPY_EDITING = `"use client";
182
+
183
+ import type { CopyContent } from "@getrefino/core";
184
+ import { RefinoProvider } from "@getrefino/react";
185
+ import type { ReactNode } from "react";
186
+ import { useCallback } from "react";
187
+
188
+ interface CopyEditingProps {
189
+ content: CopyContent;
190
+ /** Decided server-side from the session cookie. */
191
+ editing: boolean;
192
+ children: ReactNode;
193
+ }
194
+
195
+ /** Client boundary around the provider so a server layout can pass onExit. */
196
+ export function CopyEditing({ content, editing, children }: CopyEditingProps) {
197
+ const onExit = useCallback(async () => {
198
+ await fetch("/api/edit-session", { method: "DELETE", credentials: "same-origin" });
199
+ window.location.assign("/");
200
+ }, []);
201
+
202
+ return (
203
+ <RefinoProvider content={content} editing={editing} endpoint="/api/copy" onExit={onExit}>
204
+ {children}
205
+ </RefinoProvider>
206
+ );
207
+ }
208
+ `;
209
+ const COPY_EDITING_CLIENT_SESSION = `"use client";
210
+
211
+ import type { CopyContent } from "@getrefino/core";
212
+ import { RefinoProvider } from "@getrefino/react";
213
+ import type { ReactNode } from "react";
214
+ import { useCallback, useEffect, useState } from "react";
215
+
216
+ interface CopyEditingProps {
217
+ content: CopyContent;
218
+ children: ReactNode;
219
+ }
220
+
221
+ /**
222
+ * Provider wrapper for a site that is served as static files. Pages cannot
223
+ * read the session cookie while rendering, so edit mode is decided in the
224
+ * browser by asking the server: GET /api/edit-session answers 204 only for
225
+ * an authenticated owner. Visitors get one small request and the plain site.
226
+ */
227
+ export function CopyEditing({ content, children }: CopyEditingProps) {
228
+ const [editing, setEditing] = useState(false);
229
+
230
+ useEffect(() => {
231
+ const controller = new AbortController();
232
+ fetch("/api/edit-session", { method: "GET", credentials: "same-origin", cache: "no-store", signal: controller.signal })
233
+ .then((response) => setEditing(response.status === 204))
234
+ .catch(() => setEditing(false));
235
+ return () => controller.abort();
236
+ }, []);
237
+
238
+ const onExit = useCallback(async () => {
239
+ await fetch("/api/edit-session", { method: "DELETE", credentials: "same-origin" });
240
+ window.location.assign("/");
241
+ }, []);
242
+
243
+ return (
244
+ <RefinoProvider content={content} editing={editing} endpoint="/api/copy" onExit={onExit}>
245
+ {children}
246
+ </RefinoProvider>
247
+ );
248
+ }
249
+ `;
250
+ const EDITOR_SESSION_NEXT = `import { cookies } from "next/headers";
251
+
252
+ import { SESSION_COOKIE, readEditorAuthConfig, verifySessionToken } from "./editor-auth";
253
+
254
+ /**
255
+ * Is the current request from the site owner? Used by the root layout (a
256
+ * server component that cannot see the Request) to decide whether to render
257
+ * edit mode. The route handlers use the same token check through
258
+ * request-handlers. If the site already has an owner login, replace the
259
+ * body of this function with that check.
260
+ */
261
+ export async function isEditorAuthenticated(): Promise<boolean> {
262
+ const store = await cookies();
263
+ const token = store.get(SESSION_COOKIE)?.value;
264
+ const config = readEditorAuthConfig();
265
+ if (!config) return false;
266
+ return verifySessionToken(config, token);
267
+ }
268
+ `;
269
+ /**
270
+ * The one implementation of both editor endpoints. Every host wrapper below
271
+ * is a few lines that hand a standard Request plus the runtime's environment
272
+ * object to these two functions. Never runs in the browser.
273
+ */
274
+ const REQUEST_HANDLERS = `/**
275
+ * Framework-neutral handlers for the two editor endpoints. Each host wrapper
276
+ * (Next.js route handler, Cloudflare Pages Function, Vercel Function,
277
+ * Netlify Function, or your own server) passes a standard Request and the
278
+ * runtime's environment object; nothing host-specific lives here.
279
+ *
280
+ * GET /api/edit-session -> 204 when the session cookie is valid, 401 otherwise
281
+ * POST /api/edit-session -> form { password } -> 303 to / with the cookie, or to /edit?error=…
282
+ * DELETE /api/edit-session -> 204 and clears the cookie
283
+ * GET /api/copy -> latest canonical copy + revision (401 without a session)
284
+ * POST /api/copy -> { baseRevision, changes } -> saved | unchanged | conflict (409)
285
+ *
286
+ * The browser never names a file, repository or branch; those come from the
287
+ * environment. Server-side only: never import this from client code.
288
+ */
289
+ import type { ContentApi } from "@getrefino/core";
290
+
291
+ import { createContentApiFor } from "./content-adapter";
292
+ import {
293
+ createSessionToken,
294
+ isSameOriginRequest,
295
+ readEditorAuthConfig,
296
+ serializeSessionCookie,
297
+ sessionTokenFromCookieHeader,
298
+ verifyPassword,
299
+ verifySessionToken,
300
+ } from "./editor-auth";
301
+
302
+ export type EditorEnv = Readonly<Record<string, string | undefined>>;
303
+
304
+ const NO_STORE = { "cache-control": "no-store" };
305
+
306
+ /** Does this request carry a valid, unexpired editor session? */
307
+ export function isEditorRequest(request: Request, env: EditorEnv): boolean {
308
+ const config = readEditorAuthConfig(env);
309
+ if (!config) return false;
310
+ return verifySessionToken(config, sessionTokenFromCookieHeader(request.headers.get("cookie")));
311
+ }
312
+
313
+ function json(body: unknown, status: number): Response {
314
+ return Response.json(body, { status, headers: NO_STORE });
315
+ }
316
+
317
+ function methodNotAllowed(allowed: readonly string[]): Response {
318
+ return new Response(null, { status: 405, headers: { allow: allowed.join(", ") } });
319
+ }
320
+
321
+ function redirectTo(request: Request, pathname: string, setCookie?: string): Response {
322
+ const headers = new Headers({ location: new URL(pathname, request.url).toString(), ...NO_STORE });
323
+ if (setCookie) headers.set("set-cookie", setCookie);
324
+ return new Response(null, { status: 303, headers });
325
+ }
326
+
327
+ function isSecure(request: Request): boolean {
328
+ const proto = request.headers.get("x-forwarded-proto") ?? new URL(request.url).protocol.replace(":", "");
329
+ return proto === "https";
330
+ }
331
+
332
+ // One API per environment object (process.env, or the per-request env of a
333
+ // serverless runtime); the adapter inside is built on first use.
334
+ const apis = new WeakMap<object, ContentApi>();
335
+
336
+ function apiFor(env: EditorEnv): ContentApi {
337
+ let api = apis.get(env);
338
+ if (!api) {
339
+ api = createContentApiFor(env);
340
+ apis.set(env, api);
341
+ }
342
+ return api;
343
+ }
344
+
345
+ export async function handleCopyRequest(request: Request, env: EditorEnv): Promise<Response> {
346
+ if (!isEditorRequest(request, env)) {
347
+ return json({ ok: false, error: { code: "UNAUTHORIZED", message: "Sign in to edit copy." } }, 401);
348
+ }
349
+ switch (request.method) {
350
+ case "GET": {
351
+ const result = await apiFor(env).load();
352
+ return json(result.body, result.status);
353
+ }
354
+ case "POST": {
355
+ if (!isSameOriginRequest(request)) {
356
+ return json({ ok: false, error: { code: "FORBIDDEN", message: "Cross-site request rejected." } }, 403);
357
+ }
358
+ let body: unknown;
359
+ try {
360
+ body = await request.json();
361
+ } catch {
362
+ return json({ ok: false, error: { code: "INVALID_REQUEST", message: "Body must be JSON." } }, 400);
363
+ }
364
+ const result = await apiFor(env).save(body);
365
+ return json(result.body, result.status);
366
+ }
367
+ default:
368
+ return methodNotAllowed(["GET", "POST"]);
369
+ }
370
+ }
371
+
372
+ export async function handleEditSessionRequest(request: Request, env: EditorEnv): Promise<Response> {
373
+ switch (request.method) {
374
+ case "GET":
375
+ return new Response(null, { status: isEditorRequest(request, env) ? 204 : 401, headers: NO_STORE });
376
+ case "POST": {
377
+ if (!isSameOriginRequest(request)) return new Response("Cross-site request rejected.", { status: 403 });
378
+ const config = readEditorAuthConfig(env);
379
+ if (!config) return redirectTo(request, "/edit?error=unconfigured");
380
+ let password: FormDataEntryValue | null = null;
381
+ try {
382
+ password = (await request.formData()).get("password");
383
+ } catch {
384
+ return new Response("Expected a form body with a password field.", { status: 400 });
385
+ }
386
+ if (typeof password !== "string" || !verifyPassword(config, password)) {
387
+ await new Promise((resolve) => setTimeout(resolve, 750));
388
+ return redirectTo(request, "/edit?error=invalid");
389
+ }
390
+ return redirectTo(request, "/", serializeSessionCookie(createSessionToken(config), { secure: isSecure(request) }));
391
+ }
392
+ case "DELETE": {
393
+ if (!isSameOriginRequest(request)) return new Response("Cross-site request rejected.", { status: 403 });
394
+ return new Response(null, { status: 204, headers: { "set-cookie": serializeSessionCookie("", { secure: isSecure(request), maxAge: 0 }), ...NO_STORE } });
395
+ }
396
+ default:
397
+ return methodNotAllowed(["GET", "POST", "DELETE"]);
398
+ }
399
+ }
400
+ `;
401
+ /* ---------- Host wrappers: thin, generated, never edited by hand ---------- */
402
+ const COPY_ROUTE_NEXT = (handlersImport) => `/**
403
+ * /api/copy for Next.js route handlers. The behaviour lives in
404
+ * ${BOILERPLATE_DIR_NAME}/request-handlers; this file only adapts the Next.js signature.
405
+ */
406
+ import { handleCopyRequest } from "${handlersImport}";
407
+
408
+ export const dynamic = "force-dynamic";
409
+
410
+ export function GET(request: Request): Promise<Response> {
411
+ return handleCopyRequest(request, process.env);
412
+ }
413
+
414
+ export function POST(request: Request): Promise<Response> {
415
+ return handleCopyRequest(request, process.env);
416
+ }
417
+ `;
418
+ const SESSION_ROUTE_NEXT = (handlersImport) => `/**
419
+ * /api/edit-session for Next.js route handlers (GET: probe, POST: login,
420
+ * DELETE: logout). The behaviour lives in ${BOILERPLATE_DIR_NAME}/request-handlers.
421
+ */
422
+ import { handleEditSessionRequest } from "${handlersImport}";
423
+
424
+ export const dynamic = "force-dynamic";
425
+
426
+ export function GET(request: Request): Promise<Response> {
427
+ return handleEditSessionRequest(request, process.env);
428
+ }
429
+
430
+ export function POST(request: Request): Promise<Response> {
431
+ return handleEditSessionRequest(request, process.env);
432
+ }
433
+
434
+ export function DELETE(request: Request): Promise<Response> {
435
+ return handleEditSessionRequest(request, process.env);
436
+ }
437
+ `;
438
+ const CLOUDFLARE_FUNCTION = (route, handlersImport) => {
439
+ const handler = route === "copy" ? "handleCopyRequest" : "handleEditSessionRequest";
440
+ return `/**
441
+ * Cloudflare Pages Function serving /api/${route}. Pages maps this file to
442
+ * that path; every method is forwarded to ${BOILERPLATE_DIR_NAME}/request-handlers.
443
+ * Configuration comes from the project's variables (context.env). The
444
+ * handlers use node:crypto, so the Pages project needs the nodejs_compat
445
+ * compatibility flag.
446
+ */
447
+ import { ${handler} } from "${handlersImport}";
448
+
449
+ interface PagesContext {
450
+ readonly request: Request;
451
+ readonly env: Readonly<Record<string, string | undefined>>;
452
+ }
453
+
454
+ export function onRequest(context: PagesContext): Promise<Response> {
455
+ return ${handler}(context.request, context.env);
456
+ }
457
+ `;
458
+ };
459
+ const NETLIFY_FUNCTION = (route, handlersImport) => {
460
+ const handler = route === "copy" ? "handleCopyRequest" : "handleEditSessionRequest";
461
+ return `/**
462
+ * Netlify Function serving /api/${route} (Functions 2.0: a default export that
463
+ * takes a Request and returns a Response, mounted at the path in config).
464
+ * Every method is forwarded to ${BOILERPLATE_DIR_NAME}/request-handlers.
465
+ */
466
+ import { ${handler} } from "${handlersImport}";
467
+
468
+ export const config = { path: "/api/${route}" };
469
+
470
+ export default function handler(request: Request): Promise<Response> {
471
+ return ${handler}(request, process.env);
472
+ }
473
+ `;
474
+ };
475
+ const VERCEL_FUNCTION = (route, handlersImport) => {
476
+ const handler = route === "copy" ? "handleCopyRequest" : "handleEditSessionRequest";
477
+ const methods = route === "copy" ? ["GET", "POST"] : ["GET", "POST", "DELETE"];
478
+ return `/**
479
+ * Vercel Function serving /api/${route} (Node.js runtime, Web-standard
480
+ * signature). Every method is forwarded to ${BOILERPLATE_DIR_NAME}/request-handlers.
481
+ */
482
+ import { ${handler} } from "${handlersImport}";
483
+
484
+ ${methods
485
+ .map((method) => `export function ${method}(request: Request): Promise<Response> {
486
+ return ${handler}(request, process.env);
487
+ }`)
488
+ .join("\n\n")}
489
+ `;
490
+ };
491
+ const EDIT_PAGE_NEXT = (sessionImport) => `import type { Metadata } from "next";
492
+
493
+ import { isEditorAuthenticated } from "${sessionImport}";
494
+
495
+ export const dynamic = "force-dynamic";
496
+
497
+ /** The owner's sign-in, not a public page. */
498
+ export const metadata: Metadata = { title: "Edit copy", robots: { index: false, follow: false } };
499
+
500
+ /** Minimal owner login. Restyle freely; keep the form action and field name. */
501
+ export default async function EditPage(props: { searchParams: Promise<{ error?: string }> }) {
502
+ const { error } = await props.searchParams;
503
+ const authenticated = await isEditorAuthenticated();
504
+ return (
505
+ <main style={{ maxWidth: 420, margin: "10vh auto", padding: 24, fontFamily: "system-ui, sans-serif" }}>
506
+ {authenticated ? (
507
+ <>
508
+ <h1>Edit mode is on</h1>
509
+ <p>Go to the site, click any text, and press Save when you are done.</p>
510
+ <a href="/">Open the site</a>
511
+ </>
512
+ ) : (
513
+ <>
514
+ <h1>Edit the words on this site</h1>
515
+ {error === "invalid" && <p role="alert">That password was not accepted.</p>}
516
+ {error === "unconfigured" && <p role="alert">Editing is not configured. Set EDITOR_PASSWORD and EDITOR_SESSION_SECRET.</p>}
517
+ <form method="post" action="/api/edit-session">
518
+ <label htmlFor="password">Editor password</label>
519
+ <br />
520
+ <input id="password" name="password" type="password" autoComplete="current-password" required />
521
+ <button type="submit">Enter edit mode</button>
522
+ </form>
523
+ </>
524
+ )}
525
+ </main>
526
+ );
527
+ }
528
+ `;
529
+ const EDIT_LAYOUT_STATIC = `import type { Metadata } from "next";
530
+ import type { ReactNode } from "react";
531
+
532
+ /** /edit is the owner's sign-in, not a public page; the page itself is a client component and cannot export metadata. */
533
+ export const metadata: Metadata = { title: "Edit copy", robots: { index: false, follow: false } };
534
+
535
+ export default function EditLayout({ children }: { children: ReactNode }) {
536
+ return children;
537
+ }
538
+ `;
539
+ const EDIT_PAGE_STATIC = `"use client";
540
+
541
+ import Link from "next/link";
542
+ import { useEffect, useState } from "react";
543
+
544
+ interface EditState {
545
+ authenticated: boolean;
546
+ error: string | null;
547
+ }
548
+
549
+ /**
550
+ * Minimal owner login for a statically exported site. The form posts to the
551
+ * serverless /api/edit-session, which sets the session cookie and redirects
552
+ * to /. Restyle freely; keep the form action and field name.
553
+ */
554
+ export default function EditPage() {
555
+ const [state, setState] = useState<EditState>({ authenticated: false, error: null });
556
+ const { authenticated, error } = state;
557
+
558
+ useEffect(() => {
559
+ // State is set only from the probe's callbacks (never synchronously in the effect).
560
+ const code = new URLSearchParams(window.location.search).get("error");
561
+ fetch("/api/edit-session", { method: "GET", credentials: "same-origin", cache: "no-store" })
562
+ .then((response) => setState({ authenticated: response.status === 204, error: code }))
563
+ .catch(() => setState({ authenticated: false, error: code }));
564
+ }, []);
565
+
566
+ return (
567
+ <main style={{ maxWidth: 420, margin: "10vh auto", padding: 24, fontFamily: "system-ui, sans-serif" }}>
568
+ {authenticated ? (
569
+ <>
570
+ <h1>Edit mode is on</h1>
571
+ <p>Go to the site, click any text, and press Save when you are done.</p>
572
+ <Link href="/">Open the site</Link>
573
+ </>
574
+ ) : (
575
+ <>
576
+ <h1>Edit the words on this site</h1>
577
+ {error === "invalid" && <p role="alert">That password was not accepted.</p>}
578
+ {error === "unconfigured" && <p role="alert">Editing is not configured on the server. Set the editor password and session secret documented in .env.example.</p>}
579
+ <form method="post" action="/api/edit-session">
580
+ <label htmlFor="password">Editor password</label>
581
+ <br />
582
+ <input id="password" name="password" type="password" autoComplete="current-password" required />
583
+ <button type="submit">Enter edit mode</button>
584
+ </form>
585
+ </>
586
+ )}
587
+ </main>
588
+ );
589
+ }
590
+ `;
591
+ export const ENV_EXAMPLE_BLOCK = `
592
+ # --- Refino (server-side only; never expose with NEXT_PUBLIC_/VITE_) ---
593
+ # Edit-mode login. Both required in production; development falls back to password "edit".
594
+ EDITOR_PASSWORD=
595
+ EDITOR_SESSION_SECRET=
596
+ # Persistence: "local" writes the copy file on disk (development), "github" commits it.
597
+ COPY_ADAPTER=local
598
+ # GitHub persistence (COPY_ADAPTER=github): fine-grained token, Contents read/write, one repo.
599
+ COPY_GITHUB_TOKEN=
600
+ COPY_GITHUB_REPO=owner/name
601
+ COPY_GITHUB_BRANCH=main
602
+ COPY_FILE_PATH=
603
+ `;
604
+ function transpileToJs(path, source) {
605
+ const output = ts.transpileModule(source, {
606
+ compilerOptions: {
607
+ target: ts.ScriptTarget.ES2022,
608
+ module: ts.ModuleKind.ESNext,
609
+ jsx: ts.JsxEmit.Preserve,
610
+ removeComments: false,
611
+ verbatimModuleSyntax: false,
612
+ },
613
+ fileName: path,
614
+ });
615
+ return { path: path.replace(/\.tsx$/, ".jsx").replace(/\.ts$/, ".js"), content: output.outputText.replace(/^export \{\};\s*$/m, "").replace(/\n{3,}/g, "\n\n") };
616
+ }
617
+ /** Files the apply step may create for a plan, in TS or JS as the project requires. */
618
+ export function boilerplateFiles(plan) {
619
+ if (plan.refino) {
620
+ const files = hostedFiles(plan);
621
+ return plan.repository.language === "javascript" ? files.map((file) => transpileToJs(file.path, file.content)) : files;
622
+ }
623
+ const dir = plan.integration.boilerplateDir;
624
+ const strategy = plan.integration.provider.strategy;
625
+ const host = plan.integration.host;
626
+ // Server-rendered hosts pass `editing` from a cookie check; everything else probes the session in the browser.
627
+ const serverRenderedEditing = strategy === "next-app-root-layout" || strategy === "next-pages-app";
628
+ const handlers = `${dir}/request-handlers.ts`;
629
+ const files = [
630
+ { path: `${dir}/editor-auth.ts`, content: EDITOR_AUTH },
631
+ { path: `${dir}/content-adapter.ts`, content: CONTENT_ADAPTER(plan.contentFile.path) },
632
+ { path: handlers, content: REQUEST_HANDLERS },
633
+ { path: `${dir}/copy-editing.tsx`, content: serverRenderedEditing ? COPY_EDITING : COPY_EDITING_CLIENT_SESSION },
634
+ ];
635
+ if (strategy === "next-app-root-layout-client-session") {
636
+ const routesDir = plan.files.find((file) => /\/edit\/page\.\w+$/.test(file.path))?.path.replace(/\/edit\/page\.\w+$/, "") ?? "app";
637
+ files.push({ path: `${routesDir}/edit/layout.tsx`, content: EDIT_LAYOUT_STATIC });
638
+ files.push({ path: `${routesDir}/edit/page.tsx`, content: EDIT_PAGE_STATIC });
639
+ }
640
+ const copyFile = host.copyEndpoint.replace(/\.\w+$/, ".ts");
641
+ const sessionFile = host.sessionEndpoint.replace(/\.\w+$/, ".ts");
642
+ switch (host.kind) {
643
+ case "next-route-handlers": {
644
+ const editPage = `${copyFile.replace(/\/api\/copy\/route\.ts$/, "")}/edit/page.tsx`;
645
+ files.push({ path: `${dir}/editor-session.ts`, content: EDITOR_SESSION_NEXT });
646
+ files.push({ path: copyFile, content: COPY_ROUTE_NEXT(relativeImport(copyFile, handlers)) });
647
+ files.push({ path: sessionFile, content: SESSION_ROUTE_NEXT(relativeImport(sessionFile, handlers)) });
648
+ files.push({ path: editPage, content: EDIT_PAGE_NEXT(relativeImport(editPage, `${dir}/editor-session.ts`)) });
649
+ break;
650
+ }
651
+ case "cloudflare-pages-functions":
652
+ files.push({ path: copyFile, content: CLOUDFLARE_FUNCTION("copy", relativeImport(copyFile, handlers)) });
653
+ files.push({ path: sessionFile, content: CLOUDFLARE_FUNCTION("edit-session", relativeImport(sessionFile, handlers)) });
654
+ break;
655
+ case "netlify-functions":
656
+ files.push({ path: copyFile, content: NETLIFY_FUNCTION("copy", relativeImport(copyFile, handlers)) });
657
+ files.push({ path: sessionFile, content: NETLIFY_FUNCTION("edit-session", relativeImport(sessionFile, handlers)) });
658
+ break;
659
+ case "vercel-functions":
660
+ files.push({ path: copyFile, content: VERCEL_FUNCTION("copy", relativeImport(copyFile, handlers)) });
661
+ files.push({ path: sessionFile, content: VERCEL_FUNCTION("edit-session", relativeImport(sessionFile, handlers)) });
662
+ break;
663
+ default:
664
+ // next-api-routes, custom-server, unknown: the agent adapts request-handlers (see the instructions).
665
+ break;
666
+ }
667
+ if (plan.repository.language === "javascript") {
668
+ return files.map((file) => transpileToJs(file.path, file.content));
669
+ }
670
+ return files;
671
+ }