@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.
package/dist/plan.js ADDED
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Turn an inspection and a scan into a serializable migration plan. The
3
+ * plan is the contract between the CLI, the agent instructions, the safe
4
+ * apply step and the verifier.
5
+ */
6
+ import { createRequire } from "node:module";
7
+ import { join } from "node:path";
8
+ import { parseCopy } from "@getrefino/core";
9
+ import { exists, isDirectory, readText } from "./fs.js";
10
+ import { appDirectory, packageManagerExec, packageManagerRun } from "./inspect.js";
11
+ import { scanCopy } from "./scan.js";
12
+ import { DEFAULT_REFINO_APP_URL, REFINO_SITE_ID_PATTERN } from "./templates-hosted.js";
13
+ export const TOOL_NAME = "refino";
14
+ /**
15
+ * The published version of this package. A prerelease build names the commit
16
+ * it came from, so plans, instructions and the dependency pins a site
17
+ * receives say exactly which build produced them.
18
+ */
19
+ export const TOOL_VERSION = createRequire(import.meta.url)("../package.json").version;
20
+ const PRERELEASE_VERSION = /^\d+\.\d+\.\d+-/;
21
+ /**
22
+ * Dependency range for the runtime packages. Any prerelease (a smoke build or
23
+ * a release candidate) is pinned exactly, so a site records the one build it
24
+ * was tested against and never floats onto a later prerelease or onto the
25
+ * eventual stable release. Stable versions get a caret.
26
+ */
27
+ export function dependencyRange(version = TOOL_VERSION) {
28
+ return PRERELEASE_VERSION.test(version) ? version : `^${version}`;
29
+ }
30
+ export const PLAN_DIR = ".refino";
31
+ export const PLAN_FILE = `${PLAN_DIR}/plan.json`;
32
+ export const INSTRUCTIONS_FILE = `${PLAN_DIR}/AGENT_INSTRUCTIONS.md`;
33
+ export const CONFIG_FILE = "refino.config.json";
34
+ export const BOILERPLATE_DIR = "refino";
35
+ export function defaultContentFile(inspection) {
36
+ if (inspection.refino.contentFile)
37
+ return inspection.refino.contentFile;
38
+ const appDir = appDirectory(inspection);
39
+ if (inspection.sourceDir === "src" || isDirectory(join(appDir, "src/content")))
40
+ return "src/content/copy.json";
41
+ return "content/copy.json";
42
+ }
43
+ function extension(inspection, jsx) {
44
+ return inspection.language === "typescript" ? (jsx ? "tsx" : "ts") : jsx ? "jsx" : "js";
45
+ }
46
+ /** Validate and normalize the Refino connection given on the command line. */
47
+ export function resolveRefinoSite(option) {
48
+ if (!option)
49
+ return null;
50
+ if (!REFINO_SITE_ID_PATTERN.test(option.siteId))
51
+ throw new Error(`Refino site id must look like site_<32 hex characters>, got ${JSON.stringify(option.siteId)}.`);
52
+ const raw = option.appUrl ?? DEFAULT_REFINO_APP_URL;
53
+ let appUrl;
54
+ try {
55
+ const url = new URL(raw);
56
+ if (url.origin !== raw.replace(/\/+$/, "") || (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1"))
57
+ throw new Error("not an origin");
58
+ appUrl = url.origin;
59
+ }
60
+ catch {
61
+ throw new Error(`Refino app URL must be an https origin such as ${DEFAULT_REFINO_APP_URL}, got ${JSON.stringify(raw)}.`);
62
+ }
63
+ return { siteId: option.siteId, appUrl };
64
+ }
65
+ export function hostLabel(kind) {
66
+ switch (kind) {
67
+ case "refino-hosted":
68
+ return "Refino-hosted copy API";
69
+ case "next-route-handlers":
70
+ return "Next.js route handler";
71
+ case "cloudflare-pages-functions":
72
+ return "Cloudflare Pages Function";
73
+ case "vercel-functions":
74
+ return "Vercel Function";
75
+ case "netlify-functions":
76
+ return "Netlify Function";
77
+ case "next-api-routes":
78
+ return "Next.js API route";
79
+ case "custom-server":
80
+ return "server route";
81
+ default:
82
+ return "serverless function";
83
+ }
84
+ }
85
+ /**
86
+ * Choose the host path that serves /api/copy and /api/edit-session. The
87
+ * behaviour is generated once (refino/request-handlers); this decides
88
+ * which thin wrapper files the host expects, and whether the tool can write
89
+ * them itself. Inspection facts only; nothing site-specific.
90
+ */
91
+ export function hostIntegration(inspection, refino = null) {
92
+ const ext = extension(inspection, false);
93
+ const provider = inspection.hosting.provider;
94
+ if (refino) {
95
+ return {
96
+ kind: "refino-hosted",
97
+ provider,
98
+ generated: true,
99
+ copyEndpoint: `${refino.appUrl}/api/sites/${refino.siteId}/copy`,
100
+ sessionEndpoint: `${refino.appUrl}/api/editor/token`,
101
+ notes: [
102
+ "Refino hosts the copy API: the browser loads and saves through it with a short-lived editor token, and Refino commits to the repository with the credential stored for this site. This deployment holds no editor password, no session secret, no repository token and no copy adapter, and needs no server functions; it works as a static export on any host.",
103
+ `Owners start editing at this site's /edit, which sends them to ${refino.appUrl} to sign in; the site must be registered there with exactly this deployment's origin (scheme, host, port) or authorization is refused.`,
104
+ ],
105
+ };
106
+ }
107
+ const apiDir = inspection.server.apiDir;
108
+ const serverless = (kind, dir, notes, generated = true) => ({
109
+ kind,
110
+ provider,
111
+ generated,
112
+ copyEndpoint: `${dir}/copy.${ext}`,
113
+ sessionEndpoint: `${dir}/edit-session.${ext}`,
114
+ notes,
115
+ });
116
+ const noFilesystem = "Production functions have no writable filesystem: set COPY_ADAPTER=github there with the COPY_GITHUB_* variables; COPY_ADAPTER=local only works in a local Node process.";
117
+ if (inspection.server.kind === "next-route-handlers") {
118
+ const base = inspection.routesDir ?? "app";
119
+ const notes = [
120
+ "Route handlers under app/api serve both endpoints; the root layout reads the session cookie, so pages are rendered per request.",
121
+ ];
122
+ if (provider === "vercel")
123
+ notes.push("Vercel: functions have no writable filesystem, so production uses COPY_ADAPTER=github; set EDITOR_PASSWORD, EDITOR_SESSION_SECRET and the COPY_* variables in the project's environment settings (Production scope), not in a committed file.");
124
+ else if (provider === "netlify")
125
+ notes.push("Netlify (Next runtime): functions have no writable filesystem, so production uses COPY_ADAPTER=github; set the EDITOR_* and COPY_* variables in the site's environment settings.");
126
+ else if (provider === "cloudflare")
127
+ notes.push("Cloudflare (OpenNext/next-on-pages): the Workers runtime has no filesystem, so production uses COPY_ADAPTER=github; node:crypto in editor-auth needs the nodejs_compat compatibility flag.");
128
+ else
129
+ notes.push("A long-running Node server can use COPY_ADAPTER=local against a checkout; serverless or read-only deployments must use COPY_ADAPTER=github.");
130
+ return { kind: "next-route-handlers", provider, generated: true, copyEndpoint: `${base}/api/copy/route.${ext}`, sessionEndpoint: `${base}/api/edit-session/route.${ext}`, notes };
131
+ }
132
+ if (inspection.server.kind === "next-api-routes") {
133
+ const base = inspection.routesDir ?? "pages";
134
+ return {
135
+ kind: "next-api-routes",
136
+ provider,
137
+ generated: false,
138
+ copyEndpoint: `${base}/api/copy.${ext}`,
139
+ sessionEndpoint: `${base}/api/edit-session.${ext}`,
140
+ notes: ["Pages Router API routes use Node's req/res objects, which the generated request-handlers do not adapt yet; the agent writes two small routes that build a Request from req and write the Response back (documented in the instructions).", noFilesystem],
141
+ };
142
+ }
143
+ if (inspection.server.kind === "custom-server") {
144
+ const dir = inspection.server.apiDir ?? "server";
145
+ return serverless("custom-server", `${dir}/refino`, ["An existing server hosts the endpoints. The agent mounts the generated request-handlers on /api/copy and /api/edit-session (a Request in, a Response out); frameworks with Web-standard handlers (Hono, h3) pass the request straight through."], false);
146
+ }
147
+ if (provider === "cloudflare" || apiDir === "functions") {
148
+ return serverless("cloudflare-pages-functions", "functions/api", [
149
+ "Cloudflare Pages Functions: functions/api/copy and functions/api/edit-session are mapped to /api/copy and /api/edit-session automatically; configuration is read from context.env (the project's variables).",
150
+ "The Workers runtime has no filesystem, so production uses COPY_ADAPTER=github; node:crypto in editor-auth needs the nodejs_compat compatibility flag on the Pages project (Settings → Functions → Compatibility flags).",
151
+ "Local test without deploying: put the variables in .dev.vars (git-ignored), build, then `npx wrangler pages dev <build output dir> --compatibility-flags nodejs_compat`.",
152
+ ]);
153
+ }
154
+ if (provider === "netlify" || apiDir === "netlify/functions") {
155
+ return serverless("netlify-functions", "netlify/functions", ["Netlify Functions 2.0: each file default-exports a Request → Response handler mounted at the path in its `config`; configuration is read from process.env (the site's environment variables).", noFilesystem]);
156
+ }
157
+ if (provider === "vercel") {
158
+ return serverless("vercel-functions", "api", ["Vercel Functions (Node.js runtime, Web-standard signature): api/copy and api/edit-session export GET/POST/DELETE handlers and are served at /api/copy and /api/edit-session; configuration is read from process.env (the project's environment variables).", noFilesystem]);
159
+ }
160
+ return serverless("unknown-serverless", "api", ["No hosting provider was identified. The behaviour is in the generated request-handlers; the agent adds one function per route in the host's format that passes the Request and the environment object through.", noFilesystem], false);
161
+ }
162
+ /** Where a serverless copy endpoint lives for the detected host. */
163
+ export function serverlessEndpointPath(inspection, _ext) {
164
+ const host = hostIntegration(inspection);
165
+ return { path: host.copyEndpoint, note: host.notes[0] ?? "" };
166
+ }
167
+ export function integrationPoints(inspection, refino = null) {
168
+ const ts = (jsx) => extension(inspection, jsx);
169
+ const notesProvider = [];
170
+ const notesEndpoint = [];
171
+ let providerFile;
172
+ let providerStrategy;
173
+ let endpointPath;
174
+ let endpointKind;
175
+ if (refino) {
176
+ const host = hostIntegration(inspection, refino);
177
+ const base = inspection.routesDir ?? (inspection.router === "next-pages" ? "pages" : "app");
178
+ providerFile =
179
+ inspection.router === "next-app"
180
+ ? (inspection.layouts.find((layout) => layout === `${base}/layout.${ts(true)}` || /^(src\/)?app\/layout\./.test(layout)) ?? inspection.layouts[0] ?? `${base}/layout.${ts(true)}`)
181
+ : inspection.router === "next-pages"
182
+ ? (inspection.layouts[0] ?? `${base}/_app.${ts(true)}`)
183
+ : (inspection.routes[0]?.file ?? inspection.entry);
184
+ providerStrategy = "refino-hosted";
185
+ notesProvider.push(`Render <CopyEditing content={copy}> from ${BOILERPLATE_DIR}/copy-editing.${ts(true)} around everything ${providerFile ?? "the root component"} renders; pass no editing prop. Edit mode is decided in the browser from the Refino editor session, so the server never reads cookies and pages can stay static.`);
186
+ return {
187
+ provider: { file: providerFile, strategy: providerStrategy, notes: notesProvider },
188
+ endpoint: { path: host.copyEndpoint, kind: "refino-hosted", notes: [host.notes[0]] },
189
+ host,
190
+ auth: { strategy: "refino-hosted", existing: inspection.auth.libraries, notes: ["Owners sign in to Refino (GitHub) and Refino authorizes this site per request; the site has no login of its own."] },
191
+ persistence: {
192
+ local: { adapter: "none (Refino writes to the repository)", filePath: defaultContentFile(inspection) },
193
+ github: { adapter: "Refino (credential stored per site in Refino)", envVars: [] },
194
+ },
195
+ boilerplateDir: BOILERPLATE_DIR,
196
+ };
197
+ }
198
+ switch (inspection.router) {
199
+ case "next-app": {
200
+ const base = inspection.routesDir ?? "app";
201
+ providerFile = inspection.layouts.find((layout) => layout === `${base}/layout.${ts(true)}` || /^(src\/)?app\/layout\./.test(layout)) ?? inspection.layouts[0] ?? `${base}/layout.${ts(true)}`;
202
+ if (inspection.server.kind === "static") {
203
+ // output: "export": the layout cannot read cookies and route handlers cannot be exported.
204
+ providerStrategy = "next-app-root-layout-client-session";
205
+ notesProvider.push(`Render <CopyEditing content={copy}> around {children} in the root layout (${providerFile}); pass no editing prop. ${inspection.nextConfig.file ?? "next.config"} sets output: "export", so the layout cannot read cookies; the generated client component ${BOILERPLATE_DIR}/copy-editing.${ts(true)} asks GET /api/edit-session in the browser and turns edit mode on only for a 204.`);
206
+ const serverless = serverlessEndpointPath(inspection, ts(false));
207
+ endpointPath = serverless.path;
208
+ endpointKind = "serverless-function";
209
+ notesEndpoint.push(`Route handlers under ${base}/api cannot be used with output: "export". ${serverless.note}`);
210
+ break;
211
+ }
212
+ providerStrategy = "next-app-root-layout";
213
+ notesProvider.push(`Render <CopyEditing content={copy} editing={await isEditorAuthenticated()}> around {children} in the root layout (${providerFile}). The layout is a server component, so the provider is wrapped by the generated client component ${BOILERPLATE_DIR}/copy-editing.${ts(true)}.`);
214
+ endpointPath = `${base}/api/copy/route.${ts(false)}`;
215
+ endpointKind = "next-route-handler";
216
+ notesEndpoint.push("GET and POST handlers wrapping createContentApi; both call isEditorAuthenticated() first.");
217
+ break;
218
+ }
219
+ case "next-pages": {
220
+ const base = inspection.routesDir ?? "pages";
221
+ providerFile = inspection.layouts[0] ?? `${base}/_app.${ts(true)}`;
222
+ providerStrategy = "next-pages-app";
223
+ notesProvider.push(`Wrap <Component {...pageProps} /> in ${providerFile} with RefinoProvider. Compute the editing flag in getServerSideProps or via a small /api/edit-session check, never from the query string.`);
224
+ endpointPath = `${base}/api/copy.${ts(false)}`;
225
+ endpointKind = "next-api-route";
226
+ notesEndpoint.push("Single API route switching on req.method, wrapping createContentApi; re-check the session cookie on every request.");
227
+ break;
228
+ }
229
+ default: {
230
+ providerFile = inspection.routes[0]?.file ?? inspection.entry;
231
+ providerStrategy = "wrap-app-component";
232
+ notesProvider.push(`Wrap the root component in ${providerFile ?? "the entry file"} with the generated ${BOILERPLATE_DIR}/copy-editing.${ts(true)} (CopyEditing). It asks GET /api/edit-session in the browser and turns edit mode on only for a 204; never derive edit mode from a query parameter.`);
233
+ if (inspection.server.kind === "custom-server") {
234
+ endpointPath = `${inspection.server.apiDir ?? "server"}/refino.${ts(false)}`;
235
+ endpointKind = "custom-server-route";
236
+ notesEndpoint.push(`Add GET/POST /api/copy to the existing server (${inspection.server.apiDir ?? "server code"}) wrapping createContentApi behind the owner check.`);
237
+ }
238
+ else {
239
+ const serverless = serverlessEndpointPath(inspection, ts(false));
240
+ endpointPath = serverless.path;
241
+ endpointKind = "serverless-function";
242
+ notesEndpoint.push(`This site has no server. Add one serverless function per route (/api/copy, /api/edit-session) wrapping createContentApi behind the session check. ${serverless.note}`);
243
+ }
244
+ }
245
+ }
246
+ const authStrategy = inspection.auth.libraries.length > 0 ? "reuse-existing-auth" : "generated-password-login";
247
+ return {
248
+ provider: { file: providerFile, strategy: providerStrategy, notes: notesProvider },
249
+ endpoint: { path: endpointPath, kind: endpointKind, notes: notesEndpoint },
250
+ host: hostIntegration(inspection),
251
+ auth: { strategy: authStrategy, existing: inspection.auth.libraries, notes: inspection.auth.notes },
252
+ persistence: {
253
+ local: { adapter: "@getrefino/core/local-file", filePath: defaultContentFile(inspection) },
254
+ github: { adapter: "@getrefino/github", envVars: ["COPY_ADAPTER", "COPY_GITHUB_TOKEN", "COPY_GITHUB_REPO", "COPY_GITHUB_BRANCH", "COPY_FILE_PATH"] },
255
+ },
256
+ boilerplateDir: BOILERPLATE_DIR,
257
+ };
258
+ }
259
+ export function verificationCommands(inspection) {
260
+ const commands = [];
261
+ const pm = inspection.packageManager;
262
+ for (const key of ["typecheck", "lint", "test", "build"]) {
263
+ const script = inspection.scripts[key];
264
+ if (script)
265
+ commands.push(packageManagerRun(pm, script));
266
+ // A TypeScript project without a typecheck script: the build may not type-check
267
+ // (Vite never does; Next can be told to ignore errors), so run tsc directly.
268
+ else if (key === "typecheck" && inspection.language === "typescript" && inspection.typescript && exists(join(appDirectory(inspection), "tsconfig.json"))) {
269
+ commands.push(packageManagerExec(pm, "tsc --noEmit"));
270
+ }
271
+ }
272
+ return commands;
273
+ }
274
+ export function buildPlan(inspection, scan, options = {}) {
275
+ const appDir = appDirectory(inspection);
276
+ const contentFile = options.contentFile ?? defaultContentFile(inspection);
277
+ const refino = resolveRefinoSite(options.refino);
278
+ const integration = integrationPoints(inspection, refino);
279
+ const routes = options.all ? inspection.routes.map((route) => route.path) : (options.routes ?? (inspection.routes.some((route) => route.path === "/") ? ["/"] : inspection.routes.slice(0, 1).map((route) => route.path)));
280
+ const includeShared = options.includeShared ?? true;
281
+ // Existing copy: a proposed id that already holds different text needs a decision, never an overwrite.
282
+ let existing = {};
283
+ const existingText = readText(join(appDir, contentFile));
284
+ if (existingText !== null) {
285
+ try {
286
+ existing = { ...parseCopy(existingText) };
287
+ }
288
+ catch {
289
+ // Invalid file (verify will report it); still read what we can for collision checks.
290
+ try {
291
+ const loose = JSON.parse(existingText);
292
+ for (const [key, value] of Object.entries(loose))
293
+ if (typeof value === "string")
294
+ existing[key] = value;
295
+ }
296
+ catch {
297
+ existing = {};
298
+ }
299
+ }
300
+ }
301
+ const candidates = scan.candidates.map((candidate) => {
302
+ if (candidate.status === "selected" && candidate.id && candidate.id in existing && existing[candidate.id] !== candidate.text) {
303
+ return { ...candidate, status: "needs-review", reason: `"${candidate.id}" already exists in ${contentFile} with different text; pick a new id or confirm the replacement.` };
304
+ }
305
+ return candidate;
306
+ });
307
+ const byScope = {};
308
+ const byFile = {};
309
+ let selected = 0;
310
+ let needsReview = 0;
311
+ let unsupported = 0;
312
+ let excluded = 0;
313
+ for (const candidate of candidates) {
314
+ if (candidate.status === "selected")
315
+ selected += 1;
316
+ else if (candidate.status === "needs-review")
317
+ needsReview += 1;
318
+ else if (candidate.status === "unsupported")
319
+ unsupported += 1;
320
+ else
321
+ excluded += 1;
322
+ if (candidate.status === "selected" || candidate.status === "needs-review") {
323
+ byScope[candidate.scope] = (byScope[candidate.scope] ?? 0) + 1;
324
+ byFile[candidate.file] = (byFile[candidate.file] ?? 0) + 1;
325
+ }
326
+ }
327
+ const files = [];
328
+ const contentExists = exists(join(appDir, contentFile));
329
+ files.push({ path: PLAN_FILE, action: "create", description: "Machine-readable migration plan for coding agents." });
330
+ files.push({ path: INSTRUCTIONS_FILE, action: "create", description: "Repository-specific instructions for a coding agent." });
331
+ files.push({ path: contentFile, action: contentExists ? "merge" : "create", description: contentExists ? "Add selected IDs that are missing; existing values are never changed." : "Canonical copy file with the selected strings." });
332
+ files.push({ path: CONFIG_FILE, action: exists(join(appDir, CONFIG_FILE)) ? "modify" : "create", description: "Tells refino verify where the content file and endpoint live." });
333
+ const nextAppWithServer = inspection.router === "next-app" && inspection.server.kind !== "static";
334
+ const host = integration.host;
335
+ if (refino) {
336
+ files.push({ path: inspection.packageJson ?? "package.json", action: "modify", description: "Add @getrefino/core and @getrefino/react to dependencies (no @getrefino/github: Refino writes to the repository)." });
337
+ files.push({ path: `${BOILERPLATE_DIR}/`, action: "create", description: "Public Refino constants, the browser-side authorization client, the provider wrapper and the /edit page component (skipped for files that already exist). No secrets, no server code." });
338
+ if (inspection.router === "next-app") {
339
+ files.push({ path: `${inspection.routesDir ?? "app"}/edit/layout.${extension(inspection, true)}`, action: "create", description: "Marks /edit noindex (the client page cannot export metadata)." });
340
+ files.push({ path: `${inspection.routesDir ?? "app"}/edit/page.${extension(inspection, true)}`, action: "create", description: "/edit: sends the owner to Refino and finishes the authorization (client component, works as a static export)." });
341
+ }
342
+ else if (inspection.router === "next-pages") {
343
+ files.push({ path: `${inspection.routesDir ?? "pages"}/edit.${extension(inspection, true)}`, action: "create", description: "/edit: sends the owner to Refino and finishes the authorization." });
344
+ }
345
+ }
346
+ else {
347
+ files.push({ path: inspection.packageJson ?? "package.json", action: "modify", description: "Add @getrefino/core, @getrefino/react and @getrefino/github to dependencies." });
348
+ files.push({ path: ".env.example", action: exists(join(appDir, ".env.example")) ? "append" : "create", description: "Document EDITOR_* and COPY_* variables (no values)." });
349
+ files.push({ path: `${BOILERPLATE_DIR}/`, action: "create", description: "Isolated auth, adapter, request-handler and provider-wrapper boilerplate (skipped for files that already exist)." });
350
+ }
351
+ if (!refino && host.generated) {
352
+ files.push({ path: host.copyEndpoint, action: "create", description: `${hostLabel(host.kind)} serving GET/POST /api/copy through ${BOILERPLATE_DIR}/request-handlers.` });
353
+ files.push({ path: host.sessionEndpoint, action: "create", description: `${hostLabel(host.kind)} serving GET/POST/DELETE /api/edit-session (probe, login, logout).` });
354
+ }
355
+ if (refino) {
356
+ // Hosted: no login page, no route handlers, no host functions.
357
+ }
358
+ else if (nextAppWithServer) {
359
+ files.push({ path: `${inspection.routesDir ?? "app"}/edit/page.${extension(inspection, true)}`, action: "create", description: "Minimal /edit login page." });
360
+ }
361
+ else if (inspection.router === "next-app") {
362
+ files.push({ path: `${inspection.routesDir ?? "app"}/edit/layout.${extension(inspection, true)}`, action: "create", description: "Marks /edit noindex (the client page cannot export metadata)." });
363
+ files.push({ path: `${inspection.routesDir ?? "app"}/edit/page.${extension(inspection, true)}`, action: "create", description: "Minimal static /edit login page (posts to the serverless /api/edit-session)." });
364
+ }
365
+ const unresolved = [];
366
+ if (!inspection.supported)
367
+ unresolved.push(...inspection.unsupportedReasons);
368
+ if (refino && inspection.router !== "next-app" && inspection.router !== "next-pages") {
369
+ unresolved.push(`Add a route for /edit that renders the generated ${BOILERPLATE_DIR}/edit-page (the owner's entry point for Refino sign-in); the instructions describe it.`);
370
+ }
371
+ if (!refino && !inspection.server.capable) {
372
+ unresolved.push(host.generated
373
+ ? `No server capability detected (${inspection.server.notes[0] ?? "static site"}); the generated ${hostLabel(host.kind)}s ${host.copyEndpoint} and ${host.sessionEndpoint} host /api/copy and /api/edit-session and must be deployed with the site.`
374
+ : `No server capability detected (${inspection.server.notes[0] ?? "static site"}); a serverless function must host /api/copy and /api/edit-session before editing works. The instructions describe it for ${inspection.hosting.provider ?? "an unidentified host"}.`);
375
+ }
376
+ if (needsReview > 0)
377
+ unresolved.push(`${needsReview} candidate(s) need a human or agent decision on naming or inclusion (status "needs-review").`);
378
+ if (unsupported > 0)
379
+ unresolved.push(`${unsupported} string(s) mix markup or interpolation and are not plain text (status "unsupported"). Split them or leave them in code.`);
380
+ // Hosted mode decides edit mode in the browser from the Refino editor session, so no page computes a flag.
381
+ if (!refino && inspection.router === "next-pages")
382
+ unresolved.push("Pages Router: the editing flag must be computed server-side per page (getServerSideProps) or via an auth-check endpoint.");
383
+ if (!refino && !host.generated)
384
+ unresolved.push(`The endpoint behaviour is generated as ${BOILERPLATE_DIR}/request-handlers; mounting it for this host (${host.kind}) is an agent task described in the instructions.`);
385
+ if (inspection.git.isRepository && inspection.git.clean === false)
386
+ unresolved.push("Working tree has uncommitted changes; commit or stash before letting an agent modify files.");
387
+ return {
388
+ version: 1,
389
+ generatedAt: (options.now ?? new Date()).toISOString(),
390
+ tool: { name: TOOL_NAME, version: TOOL_VERSION },
391
+ repository: {
392
+ root: inspection.root,
393
+ appRoot: inspection.appRoot,
394
+ framework: inspection.framework,
395
+ router: inspection.router,
396
+ language: inspection.language,
397
+ packageManager: inspection.packageManager,
398
+ react: inspection.react?.version ?? null,
399
+ next: inspection.next?.version ?? null,
400
+ scripts: inspection.scripts,
401
+ git: inspection.git,
402
+ hosting: inspection.hosting.provider,
403
+ auth: inspection.auth.libraries,
404
+ server: inspection.server.kind,
405
+ // Mode-aware: the self-hosted prescriptions ("the copy endpoint must be a
406
+ // serverless function", "the endpoint belongs in functions/") contradict a
407
+ // hosted plan, where Refino serves the copy API and the site has none.
408
+ serverNotes: refino ? inspection.server.notes : [...inspection.server.notes, ...inspection.server.selfHosted],
409
+ },
410
+ scope: {
411
+ routes,
412
+ includeShared,
413
+ description: options.all ? "Every discovered route plus shared chrome." : `${routes.join(", ") || "no routes"}${includeShared ? " plus shared chrome (layouts, navigation, footer)" : ""}.`,
414
+ },
415
+ contentFile: { path: contentFile, exists: contentExists },
416
+ configFile: { path: CONFIG_FILE, exists: exists(join(appDir, CONFIG_FILE)) },
417
+ integration,
418
+ refino,
419
+ copy: candidates,
420
+ summary: { selected, needsReview, unsupported, excluded, byScope, byFile },
421
+ files,
422
+ verification: { commands: verificationCommands(inspection), cli: "npx @getrefino/cli verify" },
423
+ unresolved,
424
+ };
425
+ }
426
+ /** Convenience: inspect + scan + plan in one call. */
427
+ export function planMigration(inspection, options = {}) {
428
+ const scanOptions = {};
429
+ resolveRefinoSite(options.refino); // fail fast on a malformed connection before scanning
430
+ if (options.routes)
431
+ scanOptions.routes = options.routes;
432
+ if (options.all !== undefined)
433
+ scanOptions.all = options.all;
434
+ if (options.includeShared !== undefined)
435
+ scanOptions.includeShared = options.includeShared;
436
+ const scan = scanCopy(inspection, scanOptions);
437
+ return buildPlan(inspection, scan, options);
438
+ }
439
+ /** The copy-file content a plan would create: selected candidates only, in scan order. */
440
+ export function planContent(plan) {
441
+ const content = {};
442
+ for (const candidate of plan.copy) {
443
+ if (candidate.status === "selected" && candidate.id && !(candidate.id in content))
444
+ content[candidate.id] = candidate.text;
445
+ }
446
+ return content;
447
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { DataflowContext } from "./dataflow.js";
2
+ import type { CopyCandidate, CopyClassification, RepositoryInspection, ScanResult } from "./types.js";
3
+ export interface ScanOptions {
4
+ /** Restrict to these route paths (files reachable from them). Shared files are included when includeShared is true. */
5
+ readonly routes?: readonly string[];
6
+ readonly includeShared?: boolean;
7
+ /** Scan every source file regardless of route reachability. */
8
+ readonly all?: boolean;
9
+ }
10
+ export interface RawCandidate {
11
+ readonly text: string;
12
+ readonly file: string;
13
+ readonly line: number;
14
+ readonly column: number;
15
+ readonly source: CopyCandidate["source"];
16
+ readonly element: string | null;
17
+ readonly key: string | null;
18
+ readonly component: string | null;
19
+ readonly scope: string;
20
+ readonly classification: CopyClassification;
21
+ readonly reason: string;
22
+ /** Context for ID generation. */
23
+ readonly context: CandidateContext;
24
+ }
25
+ export interface CandidateContext {
26
+ /** Ancestor JSX tags, outermost first, within the enclosing component. */
27
+ readonly ancestors: readonly string[];
28
+ /** Nearest landmark hint: id or first className token of a section-like ancestor. */
29
+ readonly landmark: string | null;
30
+ readonly className: string | null;
31
+ /** Name of the array variable when the candidate comes from a data object. */
32
+ readonly collection: string | null;
33
+ /** Explicit id/key/slug/name of the data item, when present. */
34
+ readonly itemKey: string | null;
35
+ /** Title-ish text of the data item / component element, for deriving keys. */
36
+ readonly itemTitle: string | null;
37
+ /** Position of this element among same-tag siblings inside the same parent. */
38
+ readonly siblingIndex: number;
39
+ /** How many same-tag siblings (including this one) share the parent. */
40
+ readonly siblingCount: number;
41
+ readonly insideMap: boolean;
42
+ /** True for an inline element (link, emphasis) that sits inside a sentence with other text. */
43
+ readonly inlineInSentence: boolean;
44
+ /** Literal path of the enclosing link (`href`, `to`), when it is a site path. Names repeated links stably. */
45
+ readonly href: string | null;
46
+ /** True when the data array lives in another module than the JSX that maps it; the collection then names the section. */
47
+ readonly collectionImported: boolean;
48
+ /**
49
+ * The value is demonstrably rendered but a person should confirm the id or
50
+ * the inclusion: an ordinal position, one of several alternative states, or
51
+ * a string a handler assigns. Forces `needs-review`.
52
+ */
53
+ readonly ambiguous?: boolean;
54
+ readonly route: string | null;
55
+ }
56
+ /** JSX text keeps entities as written (`&amp;`); the copy file must hold the real characters. */
57
+ export declare function decodeJsxEntities(text: string): string;
58
+ /** Tailwind-style utility tokens (`sticky`, `py-24`, `lg:grid-cols-2`, `text-[1rem]`) never name a section. */
59
+ export declare function isUtilityClass(token: string): boolean;
60
+ export interface ScanFileOptions {
61
+ /** Shared across the files of one scan so a data module mapped by two components is reported once. */
62
+ readonly emitted?: Set<string>;
63
+ /** Shared component-render lookups, so one component is analysed once per scan. */
64
+ readonly flow?: DataflowContext;
65
+ }
66
+ export declare function scanFile(appDir: string, absoluteFile: string, scope: string, route: string | null, options?: ScanFileOptions): RawCandidate[];
67
+ export declare function scanCopy(inspection: RepositoryInspection, options?: ScanOptions): ScanResult;