@getrefino/onboarding 0.1.0-rc.4 → 0.1.0-rc.6

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.
@@ -11,8 +11,16 @@
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";
22
+ /** The release that wrote the rc.5 bodies below: the published 0.1.0-rc.5. */
23
+ export const RC5 = "0.1.0-rc.5";
16
24
  /** 0.1.0-rc.1 refino/refino-site.ts: the two public constants, before REFINO_INSTALL. */
17
25
  export const REFINO_SITE_RC1 = (config) => `/**
18
26
  * Public Refino configuration for this site. Neither value is a secret:
@@ -219,3 +227,560 @@ export function CopyEditing({ content, children }: CopyEditingProps) {
219
227
  );
220
228
  }
221
229
  `;
230
+ /** 0.1.0-rc.4 refino/refino-client.ts: before refinoUpdates, and before reportEditorEvent carried the editing surface. */
231
+ export const REFINO_CLIENT_RC4 = `/**
232
+ * Browser-side Refino editor authorization for this site. No secrets here:
233
+ * the site is a public client (OAuth 2.1 with PKCE). The editor token lives
234
+ * in sessionStorage only (never localStorage, never a URL), is scoped to
235
+ * this site, and Refino re-checks the account's entitlement on every load
236
+ * and save. Never import this from server code.
237
+ */
238
+ import { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from "./refino-site";
239
+
240
+ const PKCE_KEY = "refino.pkce";
241
+ const SESSION_KEY = "refino.editor";
242
+ const CALLBACK_PATH = "/edit";
243
+
244
+ export interface EditorSession {
245
+ readonly token: string;
246
+ /** Unix seconds. */
247
+ readonly expiresAt: number;
248
+ readonly siteId: string;
249
+ }
250
+
251
+ interface PendingAuthorization {
252
+ readonly verifier: string;
253
+ readonly state: string;
254
+ readonly returnTo: string;
255
+ }
256
+
257
+ function base64url(bytes: Uint8Array): string {
258
+ let binary = "";
259
+ for (const byte of bytes) binary += String.fromCharCode(byte);
260
+ return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
261
+ }
262
+
263
+ function randomToken(bytes: number): string {
264
+ const buffer = new Uint8Array(bytes);
265
+ crypto.getRandomValues(buffer);
266
+ return base64url(buffer);
267
+ }
268
+
269
+ async function sha256(text: string): Promise<string> {
270
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
271
+ return base64url(new Uint8Array(digest));
272
+ }
273
+
274
+ function storage(): Storage | null {
275
+ try {
276
+ return typeof window === "undefined" ? null : window.sessionStorage;
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ function readJson<T>(key: string): T | null {
283
+ const raw = storage()?.getItem(key);
284
+ if (!raw) return null;
285
+ try {
286
+ return JSON.parse(raw) as T;
287
+ } catch {
288
+ return null;
289
+ }
290
+ }
291
+
292
+ /** Only a path on this site, never another origin. */
293
+ export function safeReturnTo(value: string | null | undefined): string {
294
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\\\") || value.length > 512) return "/";
295
+ return value;
296
+ }
297
+
298
+ export function editorEndpoint(): string {
299
+ return \`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/copy\`;
300
+ }
301
+
302
+ export function callbackUrl(): string {
303
+ return \`\${window.location.origin}\${CALLBACK_PATH}\`;
304
+ }
305
+
306
+ /** The current editor session, or null when there is none or it expired. */
307
+ export function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {
308
+ const session = readJson<EditorSession>(SESSION_KEY);
309
+ if (!session || typeof session.token !== "string" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== "number") return null;
310
+ if (session.expiresAt <= nowSeconds) {
311
+ storage()?.removeItem(SESSION_KEY);
312
+ return null;
313
+ }
314
+ return session;
315
+ }
316
+
317
+ export function clearEditorSession(): void {
318
+ storage()?.removeItem(SESSION_KEY);
319
+ storage()?.removeItem(PKCE_KEY);
320
+ }
321
+
322
+ /** Start the flow: remember the PKCE verifier and state, then go to Refino. */
323
+ export async function beginAuthorization(returnTo: string): Promise<void> {
324
+ const verifier = randomToken(32);
325
+ const state = randomToken(16);
326
+ const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };
327
+ storage()?.setItem(PKCE_KEY, JSON.stringify(pending));
328
+ const params = new URLSearchParams({
329
+ site_id: REFINO_SITE_ID,
330
+ redirect_uri: callbackUrl(),
331
+ code_challenge: await sha256(verifier),
332
+ code_challenge_method: "S256",
333
+ state,
334
+ });
335
+ window.location.assign(\`\${REFINO_APP_URL}/editor/authorize?\${params.toString()}\`);
336
+ }
337
+
338
+ export type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };
339
+
340
+ /**
341
+ * Finish the flow on /edit?code&state: check the state, exchange the code
342
+ * with the verifier this browser kept, store the session, and scrub the
343
+ * code from the URL. The token is never placed in a URL.
344
+ */
345
+ export async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {
346
+ const params = new URLSearchParams(search);
347
+ const code = params.get("code");
348
+ const state = params.get("state");
349
+ const pending = readJson<PendingAuthorization>(PKCE_KEY);
350
+ storage()?.removeItem(PKCE_KEY);
351
+ if (typeof window !== "undefined" && window.history.replaceState) window.history.replaceState(null, "", window.location.pathname);
352
+ if (!code || !state) return { ok: false, error: "Refino did not return an authorization code." };
353
+ if (!pending || pending.state !== state) return { ok: false, error: "Sign-in was interrupted (state mismatch). Start again." };
354
+
355
+ let response: Response;
356
+ try {
357
+ response = await fetch(\`\${REFINO_APP_URL}/api/editor/token\`, {
358
+ method: "POST",
359
+ mode: "cors",
360
+ credentials: "omit",
361
+ headers: { "content-type": "application/json", accept: "application/json" },
362
+ body: JSON.stringify({
363
+ grant_type: "authorization_code",
364
+ code,
365
+ code_verifier: pending.verifier,
366
+ site_id: REFINO_SITE_ID,
367
+ redirect_uri: callbackUrl(),
368
+ // The public install facts from refino-site.ts, sent once per
369
+ // sign-in. Refino records them the first time and ignores them
370
+ // afterwards. Remove this line and everything still works.
371
+ install: REFINO_INSTALL,
372
+ }),
373
+ });
374
+ } catch {
375
+ return { ok: false, error: "Could not reach Refino to finish signing in." };
376
+ }
377
+ const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;
378
+ if (!response.ok || !body?.ok || typeof body.token !== "string" || typeof body.expiresAt !== "number" || body.siteId !== REFINO_SITE_ID) {
379
+ return { ok: false, error: body?.error?.message ?? "Refino did not accept the authorization." };
380
+ }
381
+ const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };
382
+ storage()?.setItem(SESSION_KEY, JSON.stringify(session));
383
+ return { ok: true, returnTo: pending.returnTo };
384
+ }
385
+
386
+ /**
387
+ * The three editor events this site reports, and the only ones it ever
388
+ * sends. There is no analytics SDK on this site: no autocapture, no session
389
+ * recording, no third-party script, no cookie. Each event is one small POST
390
+ * to Refino carrying an event name and, for a revert, whether it was one
391
+ * word or all of them — never your page, your copy, your visitors or your
392
+ * repository. \`edit_saved\` is not in this list on purpose: a save is a
393
+ * commit, and Refino records that on its own side where it cannot be faked
394
+ * or lost.
395
+ */
396
+ const REPORTABLE_EVENTS = ["editor_opened", "edit_started", "edit_reverted"] as const;
397
+
398
+ type ReportableEvent = (typeof REPORTABLE_EVENTS)[number];
399
+
400
+ interface EditorEventLike {
401
+ readonly name: string;
402
+ readonly properties: Readonly<Record<string, unknown>>;
403
+ }
404
+
405
+ /**
406
+ * Fire and forget. A failure here is invisible and harmless: nothing in the
407
+ * editor waits for it, retries it, or behaves differently when it fails.
408
+ */
409
+ export function reportEditorEvent(event: EditorEventLike): void {
410
+ const session = readEditorSession();
411
+ if (!session) return;
412
+ if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;
413
+ const name = event.name as ReportableEvent;
414
+ const scope = event.properties.scope;
415
+ try {
416
+ void fetch(\`\${REFINO_APP_URL}/api/editor/events\`, {
417
+ method: "POST",
418
+ mode: "cors",
419
+ credentials: "omit",
420
+ keepalive: true,
421
+ headers: { "content-type": "application/json", authorization: \`Bearer \${session.token}\` },
422
+ body: JSON.stringify(scope === "one" || scope === "all" ? { event: name, scope } : { event: name }),
423
+ }).catch(() => {});
424
+ } catch {
425
+ // Never let reporting affect editing.
426
+ }
427
+ }
428
+ `;
429
+ /** 0.1.0-rc.4 refino/copy-editing.tsx: before updates={refinoUpdates}. */
430
+ export const COPY_EDITING_HOSTED_RC4 = `"use client";
431
+
432
+ import type { CopyContent } from "@getrefino/core";
433
+ import { RefinoProvider } from "@getrefino/react";
434
+ import type { ReactNode } from "react";
435
+ import { useCallback, useEffect, useState } from "react";
436
+
437
+ import { clearEditorSession, editorEndpoint, readEditorSession, reportEditorEvent } from "./refino-client";
438
+ import type { EditorSession } from "./refino-client";
439
+
440
+ interface CopyEditingProps {
441
+ content: CopyContent;
442
+ children: ReactNode;
443
+ }
444
+
445
+ /**
446
+ * Hosted mode (Refino). Visitors get the plain site: the server never
447
+ * decides edit mode, so pages can stay static. In the browser, an editor
448
+ * session stored by /edit turns edit mode on; loads and saves go to Refino
449
+ * with the session's bearer token, and Refino checks the site's
450
+ * entitlement on every request.
451
+ */
452
+ export function CopyEditing({ content, children }: CopyEditingProps) {
453
+ const [session, setSession] = useState<EditorSession | null>(null);
454
+
455
+ useEffect(() => {
456
+ // Read the session only after mount so server-rendered HTML never differs from the visitor's.
457
+ setSession(readEditorSession());
458
+ }, []);
459
+
460
+ // Read at request time (the provider keeps its options from the first render), so the
461
+ // token is always the one currently stored and never sent once the session is cleared.
462
+ const headers = useCallback((): Record<string, string> => {
463
+ const current = readEditorSession();
464
+ return current ? { authorization: \`Bearer \${current.token}\` } : {};
465
+ }, []);
466
+
467
+ const onExit = useCallback(async () => {
468
+ clearEditorSession();
469
+ window.location.assign("/");
470
+ }, []);
471
+
472
+ return (
473
+ <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit} onEvent={reportEditorEvent}>
474
+ {children}
475
+ </RefinoProvider>
476
+ );
477
+ }
478
+ `;
479
+ /**
480
+ * 0.1.0-rc.5 refino/refino-client.ts: before `resolveEditorPhase`, `signOut`
481
+ * and the hardened `safeReturnTo`. This is what every rc.5 site has on disk.
482
+ */
483
+ export const REFINO_CLIENT_RC5 = `/**
484
+ * Browser-side Refino editor authorization for this site. No secrets here:
485
+ * the site is a public client (OAuth 2.1 with PKCE). The editor token lives
486
+ * in sessionStorage only (never localStorage, never a URL), is scoped to
487
+ * this site, and Refino re-checks the account's entitlement on every load
488
+ * and save. Never import this from server code.
489
+ */
490
+ import { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from "./refino-site";
491
+
492
+ const PKCE_KEY = "refino.pkce";
493
+ const SESSION_KEY = "refino.editor";
494
+ const CALLBACK_PATH = "/edit";
495
+
496
+ export interface EditorSession {
497
+ readonly token: string;
498
+ /** Unix seconds. */
499
+ readonly expiresAt: number;
500
+ readonly siteId: string;
501
+ }
502
+
503
+ interface PendingAuthorization {
504
+ readonly verifier: string;
505
+ readonly state: string;
506
+ readonly returnTo: string;
507
+ }
508
+
509
+ function base64url(bytes: Uint8Array): string {
510
+ let binary = "";
511
+ for (const byte of bytes) binary += String.fromCharCode(byte);
512
+ return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
513
+ }
514
+
515
+ function randomToken(bytes: number): string {
516
+ const buffer = new Uint8Array(bytes);
517
+ crypto.getRandomValues(buffer);
518
+ return base64url(buffer);
519
+ }
520
+
521
+ async function sha256(text: string): Promise<string> {
522
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
523
+ return base64url(new Uint8Array(digest));
524
+ }
525
+
526
+ function storage(): Storage | null {
527
+ try {
528
+ return typeof window === "undefined" ? null : window.sessionStorage;
529
+ } catch {
530
+ return null;
531
+ }
532
+ }
533
+
534
+ function readJson<T>(key: string): T | null {
535
+ const raw = storage()?.getItem(key);
536
+ if (!raw) return null;
537
+ try {
538
+ return JSON.parse(raw) as T;
539
+ } catch {
540
+ return null;
541
+ }
542
+ }
543
+
544
+ /** Only a path on this site, never another origin. */
545
+ export function safeReturnTo(value: string | null | undefined): string {
546
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\\\") || value.length > 512) return "/";
547
+ return value;
548
+ }
549
+
550
+ export function editorEndpoint(): string {
551
+ return \`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/copy\`;
552
+ }
553
+
554
+ export function callbackUrl(): string {
555
+ return \`\${window.location.origin}\${CALLBACK_PATH}\`;
556
+ }
557
+
558
+ /** The current editor session, or null when there is none or it expired. */
559
+ export function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {
560
+ const session = readJson<EditorSession>(SESSION_KEY);
561
+ if (!session || typeof session.token !== "string" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== "number") return null;
562
+ if (session.expiresAt <= nowSeconds) {
563
+ storage()?.removeItem(SESSION_KEY);
564
+ return null;
565
+ }
566
+ return session;
567
+ }
568
+
569
+ export function clearEditorSession(): void {
570
+ storage()?.removeItem(SESSION_KEY);
571
+ storage()?.removeItem(PKCE_KEY);
572
+ }
573
+
574
+ /** Start the flow: remember the PKCE verifier and state, then go to Refino. */
575
+ export async function beginAuthorization(returnTo: string): Promise<void> {
576
+ const verifier = randomToken(32);
577
+ const state = randomToken(16);
578
+ const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };
579
+ storage()?.setItem(PKCE_KEY, JSON.stringify(pending));
580
+ const params = new URLSearchParams({
581
+ site_id: REFINO_SITE_ID,
582
+ redirect_uri: callbackUrl(),
583
+ code_challenge: await sha256(verifier),
584
+ code_challenge_method: "S256",
585
+ state,
586
+ });
587
+ window.location.assign(\`\${REFINO_APP_URL}/editor/authorize?\${params.toString()}\`);
588
+ }
589
+
590
+ export type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };
591
+
592
+ /**
593
+ * Finish the flow on /edit?code&state: check the state, exchange the code
594
+ * with the verifier this browser kept, store the session, and scrub the
595
+ * code from the URL. The token is never placed in a URL.
596
+ */
597
+ export async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {
598
+ const params = new URLSearchParams(search);
599
+ const code = params.get("code");
600
+ const state = params.get("state");
601
+ const pending = readJson<PendingAuthorization>(PKCE_KEY);
602
+ storage()?.removeItem(PKCE_KEY);
603
+ if (typeof window !== "undefined" && window.history.replaceState) window.history.replaceState(null, "", window.location.pathname);
604
+ if (!code || !state) return { ok: false, error: "Refino did not return an authorization code." };
605
+ if (!pending || pending.state !== state) return { ok: false, error: "Sign-in was interrupted (state mismatch). Start again." };
606
+
607
+ let response: Response;
608
+ try {
609
+ response = await fetch(\`\${REFINO_APP_URL}/api/editor/token\`, {
610
+ method: "POST",
611
+ mode: "cors",
612
+ credentials: "omit",
613
+ headers: { "content-type": "application/json", accept: "application/json" },
614
+ body: JSON.stringify({
615
+ grant_type: "authorization_code",
616
+ code,
617
+ code_verifier: pending.verifier,
618
+ site_id: REFINO_SITE_ID,
619
+ redirect_uri: callbackUrl(),
620
+ // The public install facts from refino-site.ts, sent once per
621
+ // sign-in. Refino records them the first time and ignores them
622
+ // afterwards. Remove this line and everything still works.
623
+ install: REFINO_INSTALL,
624
+ }),
625
+ });
626
+ } catch {
627
+ return { ok: false, error: "Could not reach Refino to finish signing in." };
628
+ }
629
+ const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;
630
+ if (!response.ok || !body?.ok || typeof body.token !== "string" || typeof body.expiresAt !== "number" || body.siteId !== REFINO_SITE_ID) {
631
+ return { ok: false, error: body?.error?.message ?? "Refino did not accept the authorization." };
632
+ }
633
+ const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };
634
+ storage()?.setItem(SESSION_KEY, JSON.stringify(session));
635
+ return { ok: true, returnTo: pending.returnTo };
636
+ }
637
+
638
+ /**
639
+ * The three editor events this site reports, and the only ones it ever
640
+ * sends. There is no analytics SDK on this site: no autocapture, no session
641
+ * recording, no third-party script, no cookie. Each event is one small POST
642
+ * to Refino carrying an event name and, for a revert, whether it was one
643
+ * word or all of them — never your page, your copy, your visitors or your
644
+ * repository. \`edit_saved\` is not in this list on purpose: a save is a
645
+ * commit, and Refino records that on its own side where it cannot be faked
646
+ * or lost.
647
+ */
648
+ const REPORTABLE_EVENTS = ["editor_opened", "edit_started", "edit_reverted"] as const;
649
+
650
+ type ReportableEvent = (typeof REPORTABLE_EVENTS)[number];
651
+
652
+ interface EditorEventLike {
653
+ readonly name: string;
654
+ readonly properties: Readonly<Record<string, unknown>>;
655
+ }
656
+
657
+ /**
658
+ * Fire and forget. A failure here is invisible and harmless: nothing in the
659
+ * editor waits for it, retries it, or behaves differently when it fails.
660
+ */
661
+ export function reportEditorEvent(event: EditorEventLike): void {
662
+ const session = readEditorSession();
663
+ if (!session) return;
664
+ if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;
665
+ const name = event.name as ReportableEvent;
666
+ const scope = event.properties.scope;
667
+ try {
668
+ void fetch(\`\${REFINO_APP_URL}/api/editor/events\`, {
669
+ method: "POST",
670
+ mode: "cors",
671
+ credentials: "omit",
672
+ keepalive: true,
673
+ headers: { "content-type": "application/json", authorization: \`Bearer \${session.token}\` },
674
+ body: JSON.stringify(scope === "one" || scope === "all" ? { event: name, scope } : { event: name }),
675
+ }).catch(() => {});
676
+ } catch {
677
+ // Never let reporting affect editing.
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Updates to this site's Refino integration, offered in the editor
683
+ * toolbar. Asking is read-only. Nothing changes unless the owner presses
684
+ * "Update Refino": Refino then commits the new Refino version to this
685
+ * repository, and the site's normal deployment takes it from there. The
686
+ * editor checks every answer itself, so this stays a plain pipe.
687
+ */
688
+ export const refinoUpdates = {
689
+ check: (): Promise<unknown> => updateRequest("GET"),
690
+ apply: (version: string): Promise<unknown> => updateRequest("POST", JSON.stringify({ version })),
691
+ };
692
+
693
+ async function updateRequest(method: "GET" | "POST", body?: string): Promise<unknown> {
694
+ const session = readEditorSession();
695
+ if (!session) return null;
696
+ const headers: Record<string, string> = { accept: "application/json", authorization: \`Bearer \${session.token}\` };
697
+ if (body) headers["content-type"] = "application/json";
698
+ try {
699
+ const response = await fetch(\`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/update\`, { method, mode: "cors", credentials: "omit", headers, body });
700
+ return await response.json();
701
+ } catch {
702
+ // Unreachable or unreadable: the editor treats it as "nothing to offer", or as an unknown outcome.
703
+ return null;
704
+ }
705
+ }
706
+ `;
707
+ /**
708
+ * refino/edit-page.tsx as every release up to and including 0.1.0-rc.5 wrote
709
+ * it (the body never changed after rc.1): the unstyled handoff screen, which
710
+ * a successful sign-in never landed on, and its "Sign in as someone else"
711
+ * button, which cleared the site's editor session but not the Refino one and
712
+ * so signed the same account straight back in.
713
+ */
714
+ export const EDIT_PAGE_HOSTED_RC5 = `"use client";
715
+
716
+ import { useEffect, useState } from "react";
717
+
718
+ import { beginAuthorization, clearEditorSession, completeAuthorization, readEditorSession, safeReturnTo } from "./refino-client";
719
+
720
+ type Phase = { kind: "loading" } | { kind: "signed-in" } | { kind: "error"; message: string };
721
+
722
+ /**
723
+ * Owner entry point for Refino-hosted editing. Visiting /edit sends the
724
+ * owner to Refino to sign in and authorize this site; Refino sends them
725
+ * back here with a one-time code, which is exchanged for an editor session
726
+ * and scrubbed from the URL. Restyle freely; keep the behaviour.
727
+ */
728
+ export default function EditPage() {
729
+ const [phase, setPhase] = useState<Phase>({ kind: "loading" });
730
+
731
+ useEffect(() => {
732
+ let cancelled = false;
733
+ const params = new URLSearchParams(window.location.search);
734
+ if (params.has("code") || params.has("state")) {
735
+ void completeAuthorization().then((result) => {
736
+ if (cancelled) return;
737
+ if (result.ok) window.location.assign(result.returnTo);
738
+ else setPhase({ kind: "error", message: result.error });
739
+ });
740
+ } else if (readEditorSession()) {
741
+ setPhase({ kind: "signed-in" });
742
+ } else if (params.get("error")) {
743
+ setPhase({ kind: "error", message: params.get("error") ?? "Sign-in failed." });
744
+ } else {
745
+ void beginAuthorization(safeReturnTo(params.get("return_to")));
746
+ }
747
+ return () => {
748
+ cancelled = true;
749
+ };
750
+ }, []);
751
+
752
+ return (
753
+ <main style={{ maxWidth: 420, margin: "10vh auto", padding: 24, fontFamily: "system-ui, sans-serif" }}>
754
+ {phase.kind === "loading" && <p>Signing you in with Refino…</p>}
755
+ {phase.kind === "signed-in" && (
756
+ <>
757
+ <h1>Edit mode is on</h1>
758
+ <p>Go to the site, click any text, and press Save when you are done.</p>
759
+ <p>
760
+ <a href="/">Open the site</a>
761
+ </p>
762
+ <button
763
+ type="button"
764
+ onClick={() => {
765
+ clearEditorSession();
766
+ setPhase({ kind: "loading" });
767
+ void beginAuthorization("/");
768
+ }}
769
+ >
770
+ Sign in as someone else
771
+ </button>
772
+ </>
773
+ )}
774
+ {phase.kind === "error" && (
775
+ <>
776
+ <h1>Could not start editing</h1>
777
+ <p role="alert">{phase.message}</p>
778
+ <button type="button" onClick={() => void beginAuthorization("/")}>
779
+ Try again
780
+ </button>
781
+ </>
782
+ )}
783
+ </main>
784
+ );
785
+ }
786
+ `;
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;
package/dist/verify.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { dirname, join, resolve } from "node:path";
8
8
  import { isContentId, parseCopy } from "@getrefino/core";
9
+ import { parseContentConfig } from "@getrefino/core/documents";
9
10
  import ts from "typescript";
10
11
  import { exists, isFile, isTestOrStoryFile, readJson, readText, rel, walkFiles } from "./fs.js";
11
12
  import { COMPARISON_SUFFIX, classifyGeneratedFiles, readGeneratedManifest } from "./generated.js";
@@ -389,6 +390,35 @@ export function verifyIntegration(rootPath, options = {}) {
389
390
  add({ id: "content-ids", status: "pass", message: "All ids are valid." });
390
391
  }
391
392
  }
393
+ // Content sources (article editing). Absent is the normal case and not a
394
+ // finding: a site that edits only copy has nothing to declare.
395
+ if (config?.content !== undefined) {
396
+ try {
397
+ const content = parseContentConfig(config.content);
398
+ const missing = content.sources.filter((source) => !exists(join(appDir, source.dir)));
399
+ if (content.sources.length === 0) {
400
+ add({ id: "content-sources", status: "warn", message: 'refino.config.json has a "content" block with no sources.', fix: 'Add a source, or remove the "content" block.' });
401
+ }
402
+ else if (missing.length > 0) {
403
+ add({
404
+ id: "content-sources",
405
+ status: "fail",
406
+ message: `Content source directories do not exist: ${missing.map((source) => `${source.id} (${source.dir})`).join(", ")}.`,
407
+ fix: "Point each source at the directory that actually holds the articles.",
408
+ });
409
+ }
410
+ else {
411
+ add({
412
+ id: "content-sources",
413
+ status: "pass",
414
+ message: `Article sources: ${content.sources.map((source) => `${source.id} (${source.dir}/*${source.extensions.join(",*")})`).join(", ")}.`,
415
+ });
416
+ }
417
+ }
418
+ catch (error) {
419
+ add({ id: "content-sources", status: "fail", message: error.message, fix: 'Fix the "content" block, or remove it to turn article editing off.' });
420
+ }
421
+ }
392
422
  // Source scan
393
423
  const sourceFiles = walkFiles(appDir).filter((file) => !isTestOrStoryFile(rel(appDir, file)));
394
424
  const uses = collectEditableTextUses(appDir, sourceFiles);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrefino/onboarding",
3
- "version": "0.1.0-rc.4",
3
+ "version": "0.1.0-rc.6",
4
4
  "description": "The engine behind `npx @getrefino/cli init`: deterministic repository inspection, copy discovery, migration planning, agent instructions and verification for adding Refino to an existing React site. Development tooling only; never part of a site's runtime.",
5
5
  "keywords": [
6
6
  "refino",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "typescript": ">=5.5 <7",
41
- "@getrefino/core": "0.1.0-rc.4"
41
+ "@getrefino/core": "0.1.0-rc.6"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "22.20.2",