@algolia/wizard 0.45.0-rc.141.320 → 0.46.0-rc.136.345

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/main.js CHANGED
@@ -239,6 +239,9 @@ function describeInputValue(value) {
239
239
  }
240
240
  var NOTICE_INTERVAL_MS = 2e3;
241
241
  var CLI_OUTPUT_LIMIT = 200;
242
+ function commandApprovalKey(command, cwd) {
243
+ return `${cwd}\0${command}`;
244
+ }
242
245
  var useWizard = create((set, get) => ({
243
246
  phase: "idle",
244
247
  homeScreen: "home",
@@ -352,10 +355,17 @@ var useWizard = create((set, get) => ({
352
355
  setTargetIndex: (index) => set({ targetIndex: index }),
353
356
  recordWrittenFile: (path) => set((s) => ({ writtenFiles: [...s.writtenFiles, path] })),
354
357
  clearWrittenFiles: () => set({ writtenFiles: [] }),
355
- isCommandApproved: (command, cwd) => get().approvedCommands.has(`${cwd}\0${command}`),
358
+ isCommandApproved: (command, cwd) => get().approvedCommands.has(commandApprovalKey(command, cwd)),
356
359
  approveCommand: (command, cwd) => set((s) => ({
357
- approvedCommands: new Set(s.approvedCommands).add(`${cwd}\0${command}`)
360
+ approvedCommands: new Set(s.approvedCommands).add(
361
+ commandApprovalKey(command, cwd)
362
+ )
358
363
  })),
364
+ revokeCommand: (command, cwd) => set((s) => {
365
+ const approvedCommands = new Set(s.approvedCommands);
366
+ approvedCommands.delete(commandApprovalKey(command, cwd));
367
+ return { approvedCommands };
368
+ }),
359
369
  logStart: (kind, name, input) => {
360
370
  const id = nanoid();
361
371
  set((s) => ({
@@ -731,9 +741,11 @@ function DecisionSelect({
731
741
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
732
742
  function CommandApproval({
733
743
  command,
744
+ messages,
734
745
  onDecide
735
746
  }) {
736
747
  return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", gap: 1, children: [
748
+ messages?.map((message, index) => /* @__PURE__ */ jsx5(Text6, { color: COLORS.muted, children: message }, `msg-${index}`)),
737
749
  /* @__PURE__ */ jsx5(Text6, { color: COLORS.primary, bold: true, children: "Run this command?" }),
738
750
  /* @__PURE__ */ jsxs5(
739
751
  Box6,
@@ -1074,7 +1086,6 @@ function PromptInput() {
1074
1086
  return /* @__PURE__ */ jsx7(Box9, { flexGrow: 1, children: /* @__PURE__ */ jsx7(
1075
1087
  SelectPrompt,
1076
1088
  {
1077
- question: inputReq.prompt,
1078
1089
  messages: inputReq.messages,
1079
1090
  options: ["Continue"],
1080
1091
  onSelect: submitInput
@@ -1093,7 +1104,14 @@ function PromptInput() {
1093
1104
  );
1094
1105
  }
1095
1106
  if (inputReq.promptType === "commandApproval" && inputReq.command) {
1096
- return /* @__PURE__ */ jsx7(CommandApproval, { command: inputReq.command, onDecide: submitInput });
1107
+ return /* @__PURE__ */ jsx7(
1108
+ CommandApproval,
1109
+ {
1110
+ command: inputReq.command,
1111
+ messages: inputReq.messages,
1112
+ onDecide: submitInput
1113
+ }
1114
+ );
1097
1115
  }
1098
1116
  if (inputReq.promptType === "acceptReject") {
1099
1117
  const labels = inputReq.options?.length ? inputReq.options : ACCEPT_REJECT_OPTIONS;
@@ -2556,9 +2574,16 @@ function searchFilesTool(ctx) {
2556
2574
  }
2557
2575
 
2558
2576
  // src/lib/tools/runShell.ts
2559
- import { tool as tool8, generateText, Output } from "ai";
2577
+ import { tool as tool9, generateText, Output } from "ai";
2560
2578
  import { createAnthropic } from "@ai-sdk/anthropic";
2579
+ import z16 from "zod";
2580
+ import { relative as relative5 } from "node:path";
2581
+
2582
+ // src/lib/tools/reviewScript.ts
2583
+ import { tool as tool8 } from "ai";
2561
2584
  import z15 from "zod";
2585
+ import { createHash } from "node:crypto";
2586
+ import { readFile as readFile7, stat as stat2 } from "node:fs/promises";
2562
2587
  import { relative as relative4 } from "node:path";
2563
2588
 
2564
2589
  // src/lib/tools/utils/prompt.ts
@@ -2570,6 +2595,116 @@ function serializePrompt(work) {
2570
2595
  return result;
2571
2596
  }
2572
2597
 
2598
+ // src/lib/tools/reviewScript.ts
2599
+ var reviewedCommandApprovals = /* @__PURE__ */ new WeakMap();
2600
+ async function fileDigest(filePath) {
2601
+ const content = await readFile7(filePath);
2602
+ return createHash("sha256").update(content).digest("hex");
2603
+ }
2604
+ function grantReviewedCommandApproval(ctx, approval) {
2605
+ reviewedCommandApprovals.set(ctx, approval);
2606
+ }
2607
+ function getReviewedCommandApproval(ctx, command, cwd) {
2608
+ const approval = reviewedCommandApprovals.get(ctx);
2609
+ if (approval?.command !== command || approval.cwd !== cwd) return void 0;
2610
+ return approval;
2611
+ }
2612
+ function clearReviewedCommandApproval(ctx) {
2613
+ reviewedCommandApprovals.delete(ctx);
2614
+ }
2615
+ function revokeCommandApprovals(ctx, command, cwd) {
2616
+ clearReviewedCommandApproval(ctx);
2617
+ useWizard.getState().revokeCommand(command, cwd);
2618
+ }
2619
+ async function reviewedCommandApprovalIsCurrent(approval) {
2620
+ const currentDigest = await fileDigest(approval.filePath).catch(() => null);
2621
+ return currentDigest === approval.digest;
2622
+ }
2623
+ function reviewScriptTool(ctx) {
2624
+ return tool8({
2625
+ description: "Call this as soon as the ingestion script is finished and before you run it. It summarizes the records that will be written, then asks the user to review the script and approve the exact command that will run it, so a matching runShell call needs no second approval.",
2626
+ inputSchema: z15.object({
2627
+ filePath: z15.string().describe(
2628
+ "Path to the script to review, relative to the project root."
2629
+ ),
2630
+ command: z15.string().min(1).describe("The exact command that runShell will use to run the script."),
2631
+ cwd: z15.string().describe(
2632
+ "The exact working directory that runShell will use, relative to the current tool working directory."
2633
+ ),
2634
+ entityName: z15.string().trim().min(1).max(100).regex(/^[^\r\n]+$/).describe("The exact entity name selected by the user."),
2635
+ usesGeneratedRecords: z15.boolean().describe(
2636
+ "Whether the script reads generated sample records instead of the selected real data source."
2637
+ )
2638
+ }),
2639
+ execute: async ({
2640
+ filePath,
2641
+ command,
2642
+ cwd,
2643
+ entityName,
2644
+ usesGeneratedRecords
2645
+ }) => {
2646
+ logger.info(
2647
+ { filePath, command, cwd, entityName, usesGeneratedRecords },
2648
+ "called reviewScript tool"
2649
+ );
2650
+ const resolved2 = resolveInRoot(ctx, filePath);
2651
+ if (resolved2.ok === false) return resolved2.error;
2652
+ const resolvedCwd = resolveInRoot(ctx, cwd);
2653
+ if (resolvedCwd.ok === false) return resolvedCwd.error;
2654
+ const displayCwd = relative4(ctx.root, resolvedCwd.target) || ".";
2655
+ const displayFilePath = relative4(ctx.root, resolved2.target);
2656
+ const ingestionSummary = usesGeneratedRecords ? `This will push ${entityName} records from generated sample data to Algolia. You can replace the sample dataset with your real data source in ${displayFilePath}.` : `This will push ${entityName} records from your selected source to Algolia.`;
2657
+ return serializePrompt(async () => {
2658
+ revokeCommandApprovals(ctx, command, resolvedCwd.target);
2659
+ const isFile = await stat2(resolved2.target).then(
2660
+ (s) => s.isFile(),
2661
+ () => false
2662
+ );
2663
+ if (!isFile) {
2664
+ return `Refused: ${filePath} is not a file. Write the script first, then review the path you wrote.`;
2665
+ }
2666
+ const reviewedDigest = await fileDigest(resolved2.target).catch(
2667
+ () => null
2668
+ );
2669
+ if (reviewedDigest === null) {
2670
+ return `Refused: ${filePath} could not be read. Write the script first, then review it again.`;
2671
+ }
2672
+ const answer = await useWizard.getState().requestUserInput({
2673
+ prompt: "Run this command?",
2674
+ promptType: "commandApproval",
2675
+ options: [],
2676
+ messages: [
2677
+ `We have created your ingestion script. Please review it at ${displayFilePath} before approving.`
2678
+ ],
2679
+ command: {
2680
+ command,
2681
+ cwd: displayCwd,
2682
+ explanation: ingestionSummary
2683
+ }
2684
+ });
2685
+ if (answer !== "approve") {
2686
+ revokeCommandApprovals(ctx, command, resolvedCwd.target);
2687
+ return "The user declined to run this ingestion script. Do not run it.";
2688
+ }
2689
+ const approvedDigest = await fileDigest(resolved2.target).catch(
2690
+ () => null
2691
+ );
2692
+ if (approvedDigest !== reviewedDigest) {
2693
+ return "The ingestion script changed while it was being reviewed. Call reviewScript again before running it.";
2694
+ }
2695
+ ctx.reviewed.push(resolved2.target);
2696
+ grantReviewedCommandApproval(ctx, {
2697
+ command,
2698
+ cwd: resolvedCwd.target,
2699
+ filePath: resolved2.target,
2700
+ digest: reviewedDigest
2701
+ });
2702
+ return "The user reviewed the script and approved this exact command. Run it now with the same cwd.";
2703
+ });
2704
+ }
2705
+ });
2706
+ }
2707
+
2573
2708
  // src/lib/tools/utils/runShell.ts
2574
2709
  import { spawn as spawn2 } from "node:child_process";
2575
2710
 
@@ -2668,7 +2803,7 @@ function storeApproval(root) {
2668
2803
  return async (req) => {
2669
2804
  const store = useWizard.getState();
2670
2805
  if (store.isCommandApproved(req.command, req.cwd)) return "approve";
2671
- const rel = relative4(root, req.cwd);
2806
+ const rel = relative5(root, req.cwd);
2672
2807
  const answer = await store.requestUserInput({
2673
2808
  prompt: "Run this command?",
2674
2809
  promptType: "commandApproval",
@@ -2821,9 +2956,9 @@ function fastPathSafety(command) {
2821
2956
  return void 0;
2822
2957
  }
2823
2958
  var CLASSIFIER_MODEL = "claude-haiku-4-5";
2824
- var commandSafetySchema = z15.object({
2825
- safe: z15.boolean(),
2826
- reason: z15.string().describe("One short sentence explaining the verdict.")
2959
+ var commandSafetySchema = z16.object({
2960
+ safe: z16.boolean(),
2961
+ reason: z16.string().describe("One short sentence explaining the verdict.")
2827
2962
  });
2828
2963
  function defaultCreateModel() {
2829
2964
  const token = getAuthToken();
@@ -2883,12 +3018,15 @@ async function classifyCommandSafety(createModel, command, cwd, explanation, app
2883
3018
  return false;
2884
3019
  }
2885
3020
  }
2886
- async function runAndRecord(ctx, command, cwd) {
2887
- useWizard.getState().pushNotice({ messages: [`Running: ${command}`] });
2888
- const env = await ctx.shell.env().catch((err) => {
3021
+ async function resolveShellEnv(ctx, command) {
3022
+ return ctx.shell.env().catch((err) => {
2889
3023
  logger.warn({ err, command }, "runShell: could not resolve command env");
2890
3024
  return {};
2891
3025
  });
3026
+ }
3027
+ async function runAndRecord(ctx, command, cwd, resolvedEnv) {
3028
+ useWizard.getState().pushNotice({ messages: [`Running: ${command}`] });
3029
+ const env = resolvedEnv ?? await resolveShellEnv(ctx, command);
2892
3030
  const run2 = await (ctx.shell.run ?? runShell)(command, {
2893
3031
  cwd,
2894
3032
  env,
@@ -2919,6 +3057,16 @@ async function runAndRecord(ctx, command, cwd) {
2919
3057
  output: run2.output
2920
3058
  };
2921
3059
  }
3060
+ async function runReviewedCommand(ctx, command, cwd) {
3061
+ const approval = getReviewedCommandApproval(ctx, command, cwd);
3062
+ if (!approval) return void 0;
3063
+ const env = await resolveShellEnv(ctx, command);
3064
+ if (!await reviewedCommandApprovalIsCurrent(approval)) {
3065
+ return `Refused: ${relative5(ctx.root, approval.filePath)} changed after review. Call reviewScript again before running it.`;
3066
+ }
3067
+ clearReviewedCommandApproval(ctx);
3068
+ return runAndRecord(ctx, command, cwd, env);
3069
+ }
2922
3070
  async function approveAndRun(ctx, command, cwd, explanation) {
2923
3071
  const decision = await ctx.shell.approve({ command, cwd, explanation });
2924
3072
  if (decision === "reject") {
@@ -2929,16 +3077,16 @@ async function approveAndRun(ctx, command, cwd, explanation) {
2929
3077
  return runAndRecord(ctx, command, cwd);
2930
3078
  }
2931
3079
  function runShellTool(ctx, createModel = defaultCreateModel) {
2932
- return tool8({
3080
+ return tool9({
2933
3081
  description: "Run a shell command in the project. Use this for anything the project needs done in its own ecosystem: installing dependencies, running a script you wrote, running the project's lint/typecheck/test commands. Do not use this to find or read files \u2014 use listFiles, searchFiles, and readFile instead of shell file-listing, reading, or search commands (ls/find/tree/cat/head/tail/grep/rg and similar). This tool refuses all of those outright: the dedicated tools enforce the project-root boundary and secret redaction that a raw shell read would bypass, and they won't count against this tool's command budget. The user approves any command that could change the project before it runs, so write a clear `explanation`. A command judged read-only (inspection, or running tests/typecheck/lint without an autofix flag, in any language) runs immediately without approval. If the user rejects a command, do not retry it \u2014 propose a different approach.",
2934
- inputSchema: z15.object({
2935
- command: z15.string().describe(
3082
+ inputSchema: z16.object({
3083
+ command: z16.string().describe(
2936
3084
  "The command to run, exactly as it would be typed in a shell. Pipes, && and redirects are allowed."
2937
3085
  ),
2938
- cwd: z15.string().optional().describe(
3086
+ cwd: z16.string().optional().describe(
2939
3087
  "Directory to run in, relative to the project root. Defaults to the project root."
2940
3088
  ),
2941
- explanation: z15.string().describe(
3089
+ explanation: z16.string().describe(
2942
3090
  "One short line telling the user what this command does and why, including any side effect (e.g. writes records to Algolia). This is what they approve against."
2943
3091
  )
2944
3092
  }),
@@ -2947,16 +3095,22 @@ function runShellTool(ctx, createModel = defaultCreateModel) {
2947
3095
  return `Refused: command limit (${ctx.limits.shell}) reached. Stop running commands and report what you have.`;
2948
3096
  }
2949
3097
  const resolved2 = resolveInRoot(ctx, cwd ?? ".");
2950
- if (!resolved2.ok) return resolved2.error;
3098
+ if (resolved2.ok === false) return resolved2.error;
2951
3099
  if (isFileIOCommand(command)) {
2952
3100
  return "Refused: use listFiles or searchFiles to find files, and readFile to read one, instead of a shell file-listing, reading, or search command (ls/find/fd/tree/dir/locate/cat/head/tail/less/more/tac/nl/grep/egrep/fgrep/rg/ack/ag/xxd/hexdump/od/strings).";
2953
3101
  }
2954
3102
  logger.info({ command, cwd: resolved2.target }, "called runShell tool");
3103
+ const reviewed = await serializePrompt(
3104
+ () => runReviewedCommand(ctx, command, resolved2.target)
3105
+ );
3106
+ if (reviewed !== void 0) return reviewed;
2955
3107
  const fast = fastPathSafety(command);
2956
3108
  let isSafe = fast;
3109
+ let usedStoredApproval = false;
2957
3110
  if (isSafe === void 0) {
2958
3111
  const store = useWizard.getState();
2959
- isSafe = store.isCommandApproved(command, resolved2.target);
3112
+ usedStoredApproval = store.isCommandApproved(command, resolved2.target);
3113
+ isSafe = usedStoredApproval;
2960
3114
  if (!isSafe) {
2961
3115
  isSafe = await classifyCommandSafety(
2962
3116
  createModel,
@@ -2967,54 +3121,18 @@ function runShellTool(ctx, createModel = defaultCreateModel) {
2967
3121
  );
2968
3122
  }
2969
3123
  }
2970
- if (isSafe) {
2971
- return serializePrompt(
2972
- () => runAndRecord(ctx, command, resolved2.target)
2973
- );
2974
- }
2975
- return serializePrompt(
2976
- () => approveAndRun(ctx, command, resolved2.target, explanation)
2977
- );
2978
- }
2979
- });
2980
- }
2981
-
2982
- // src/lib/tools/reviewScript.ts
2983
- import { tool as tool9 } from "ai";
2984
- import z16 from "zod";
2985
- import { stat as stat2 } from "node:fs/promises";
2986
- import { relative as relative5 } from "node:path";
2987
- function reviewScriptTool(ctx) {
2988
- return tool9({
2989
- description: "Call this as soon as the ingestion script is finished and before you run it, so the user can review it.It shows the file path and returns once they have confirmed reviewing it.",
2990
- inputSchema: z16.object({
2991
- filePath: z16.string().describe(
2992
- "Path to the script to review, relative to the project root."
2993
- )
2994
- }),
2995
- execute: async ({ filePath }) => {
2996
- logger.info({ filePath }, "called reviewScript tool");
2997
- const resolved2 = resolveInRoot(ctx, filePath);
2998
- if (resolved2.ok === false) return resolved2.error;
2999
- const isFile = await stat2(resolved2.target).then(
3000
- (s) => s.isFile(),
3001
- () => false
3002
- );
3003
- if (!isFile) {
3004
- return `Refused: ${filePath} is not a file. Write the script first, then review the path you wrote.`;
3005
- }
3006
3124
  return serializePrompt(async () => {
3007
- await useWizard.getState().requestUserInput({
3008
- prompt: "",
3009
- promptType: "enterToContinue",
3010
- options: [],
3011
- messages: [
3012
- `Please review ${relative5(ctx.root, resolved2.target)} before it runs:`,
3013
- resolved2.target
3014
- ]
3015
- });
3016
- ctx.reviewed.push(resolved2.target);
3017
- return "The user has reviewed the script. You may run it now.";
3125
+ const lateReview = await runReviewedCommand(
3126
+ ctx,
3127
+ command,
3128
+ resolved2.target
3129
+ );
3130
+ if (lateReview !== void 0) return lateReview;
3131
+ const storedApprovalIsCurrent = !usedStoredApproval || useWizard.getState().isCommandApproved(command, resolved2.target);
3132
+ if (isSafe && storedApprovalIsCurrent) {
3133
+ return runAndRecord(ctx, command, resolved2.target);
3134
+ }
3135
+ return approveAndRun(ctx, command, resolved2.target, explanation);
3018
3136
  });
3019
3137
  }
3020
3138
  });
@@ -3368,22 +3486,59 @@ var detectLanguageSchema = z21.object({
3368
3486
  `The prefix a bundler/framework requires for an env var to reach client-side code (e.g. "NEXT_PUBLIC_", "VITE_", "NUXT_PUBLIC_"), uppercase with a trailing underscore when non-empty. Prefer the project's own existing convention over a framework default. Empty string when the project has no client-side env var exposure (e.g. a backend-only project).`
3369
3487
  )
3370
3488
  });
3371
- var detectLanguage = () => runAgent({
3372
- instructions: [
3373
- "Analyze the codebase and determine the programming languages and frameworks used",
3374
- "If a superset language is found, exclude the subset language (e.g. TypeScript over JavaScript).",
3375
- "If a meta-framework is used, exclude the framework it builds on (e.g. Next.js over React, Rails over Rack).",
3376
- "Return the exact version",
3377
- "Exclude things like CSS frameworks, build tools, or testing frameworks",
3378
- `Determine publicEnvVarPrefix: check the project's own env var usage first (e.g. names already referenced in code, .env/.env.example); if none exists, fall back to the detected framework's known convention for exposing env vars to client-side code; use "" when the project has no such convention (e.g. a backend-only project).`,
3379
- "A brand-new project has no existing env var usage to find \u2014 one or two targeted checks (e.g. .env/.env.example, or a grep for the bundler's public-prefix convention) are enough to confirm that. Do not keep searching once those turn up nothing; fall back to the framework convention immediately.",
3380
- 'Use as few tools as possible, but do not guess. If you cant find the answer, say "unknown"',
3381
- "When done, call reportStatus"
3382
- ],
3383
- tools: ["listFiles", "changeDirectory", "readFile", "searchFiles"],
3384
- outputSchema: detectLanguageSchema,
3385
- modelSize: "small"
3386
- });
3489
+ var FRONTEND_FRAMEWORKS = /* @__PURE__ */ new Set([
3490
+ "angular",
3491
+ "astro",
3492
+ "ember",
3493
+ "javascript",
3494
+ "next",
3495
+ "nextjs",
3496
+ "nuxt",
3497
+ "preact",
3498
+ "qwik",
3499
+ "react",
3500
+ "remix",
3501
+ "solid",
3502
+ "solidjs",
3503
+ "svelte",
3504
+ "sveltekit",
3505
+ "vanilla",
3506
+ "vanillajs",
3507
+ "vue",
3508
+ "vuejs"
3509
+ ]);
3510
+ var normalizeFramework = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
3511
+ function prioritizeFrontendFrameworks(result) {
3512
+ const frontend = result.frameworks.filter(
3513
+ (framework) => FRONTEND_FRAMEWORKS.has(normalizeFramework(framework.name))
3514
+ );
3515
+ if (frontend.length === 0) return result;
3516
+ const remaining = result.frameworks.filter(
3517
+ (framework) => !FRONTEND_FRAMEWORKS.has(normalizeFramework(framework.name))
3518
+ );
3519
+ return { ...result, frameworks: [...frontend, ...remaining] };
3520
+ }
3521
+ var detectLanguage = async () => {
3522
+ const result = await runAgent({
3523
+ instructions: [
3524
+ "Analyze the codebase and determine the programming languages and frameworks used",
3525
+ "If a superset language is found, exclude the subset language (e.g. TypeScript over JavaScript).",
3526
+ "For frameworks, inspect the code that implements the UI and prioritize the frontend framework used by the primary UI.",
3527
+ "When a separate frontend framework exists, omit backend frameworks (e.g. report React, not Rails, when Rails serves a React UI). If no separate frontend framework exists, report the server framework that renders the UI.",
3528
+ "If a frontend meta-framework is used, exclude the framework it builds on (e.g. Next.js over React).",
3529
+ "Return the exact version",
3530
+ "Exclude things like CSS frameworks, build tools, or testing frameworks",
3531
+ `Determine publicEnvVarPrefix: check the project's own env var usage first (e.g. names already referenced in code, .env/.env.example); if none exists, fall back to the detected framework's known convention for exposing env vars to client-side code; use "" when the project has no such convention (e.g. a backend-only project).`,
3532
+ "A brand-new project has no existing env var usage to find \u2014 one or two targeted checks (e.g. .env/.env.example, or a grep for the bundler's public-prefix convention) are enough to confirm that. Do not keep searching once those turn up nothing; fall back to the framework convention immediately.",
3533
+ 'Use as few tools as possible, but do not guess. If you cant find the answer, say "unknown"',
3534
+ "When done, call reportStatus"
3535
+ ],
3536
+ tools: ["listFiles", "changeDirectory", "readFile", "searchFiles"],
3537
+ outputSchema: detectLanguageSchema,
3538
+ modelSize: "small"
3539
+ });
3540
+ return prioritizeFrontendFrameworks(result);
3541
+ };
3387
3542
 
3388
3543
  // src/actions/analyzeCodebase.ts
3389
3544
  import z22 from "zod";
@@ -3472,7 +3627,7 @@ async function runAnalysis(mode, extraInstructions = []) {
3472
3627
  // package.json
3473
3628
  var package_default = {
3474
3629
  name: "@algolia/wizard",
3475
- version: "0.45.0-rc.141.320",
3630
+ version: "0.46.0-rc.136.345",
3476
3631
  description: "Magically implement Algolia functionality in your codebase",
3477
3632
  type: "module",
3478
3633
  engines: {
@@ -3661,7 +3816,7 @@ function confirmed(name, version) {
3661
3816
  return { frameworks };
3662
3817
  }
3663
3818
  async function askOtherFramework(ctx) {
3664
- let prompt = "enter your framework";
3819
+ let prompt = "enter the framework used for your frontend";
3665
3820
  for (; ; ) {
3666
3821
  const answer = await ctx.requestUserInput({
3667
3822
  prompt,
@@ -3673,7 +3828,7 @@ async function askOtherFramework(ctx) {
3673
3828
  }
3674
3829
  const name = parseEntries(answer)[0]?.name;
3675
3830
  if (name) return name;
3676
- prompt = "please enter a framework name:";
3831
+ prompt = "please enter the framework used for your frontend:";
3677
3832
  }
3678
3833
  }
3679
3834
  async function confirmFramework(ctx) {
@@ -3688,9 +3843,12 @@ async function confirmFramework(ctx) {
3688
3843
  const primary = detectedFrameworks[0];
3689
3844
  if (primary) {
3690
3845
  const accepted = await ctx.requestUserInput({
3691
- prompt: `Use ${primary.name}?`,
3846
+ prompt: `Use ${primary.name} for your frontend?`,
3692
3847
  promptType: "acceptReject",
3693
- options: [`Confirm ${primary.name}`, "Use a different framework"],
3848
+ options: [
3849
+ `Confirm ${primary.name}`,
3850
+ "Use a different framework for your frontend"
3851
+ ],
3694
3852
  secondary: [{ kind: "badge", value: "[DETECTED]" }, void 0]
3695
3853
  });
3696
3854
  if (accepted === true) return confirmed(primary.name, primary.version);
@@ -3703,7 +3861,7 @@ async function confirmFramework(ctx) {
3703
3861
  0
3704
3862
  );
3705
3863
  const selection = await ctx.requestUserInput({
3706
- prompt: "select a framework",
3864
+ prompt: "select a framework for your frontend",
3707
3865
  promptType: "multipleChoice",
3708
3866
  options,
3709
3867
  secondary,
@@ -4172,8 +4330,8 @@ function ingestionInstructions(input) {
4172
4330
  ...algoliaClientDoc(input),
4173
4331
  "Install the Algolia client with the project's own package manager via runShell, declaring it in whatever manifest the project uses (e.g. package.json, requirements.txt, Gemfile, go.mod, composer.json) so the dependency is not just installed ad hoc.",
4174
4332
  `If the script loads its env vars from a file (e.g. via dotenv or an equivalent for its language) rather than the process environment directly, decide that up front and call writeCredentials on that file before you finish the script \u2014 do not wait to discover the need for it by having a writeFile call refused.`,
4175
- "When the script is finished, call reviewScript with its path and wait: running it writes records to a live index, so the developer reads it first. Do not run it before that call returns.",
4176
- 'Then run the script yourself via runShell, and report the command you ran as "ingestCommand" so the developer can re-run it. Its explanation must say that running it writes records to Algolia.',
4333
+ `When the script is finished, decide the exact command and cwd you will use to run it, then call reviewScript with that filePath, command, cwd, the exact selected entityName, and usesGeneratedRecords set to ${input.ingestionSource === "generated"}. Wait for the developer to review it because running it writes records to a live index. Do not run it before that call returns.`,
4334
+ 'Then, without changing directories, run the script yourself via runShell using the exact same command and cwd. Report the command you ran as "ingestCommand" so the developer can re-run it. Its explanation must say that running it writes records to Algolia.',
4177
4335
  "The summary should be extremely concise.",
4178
4336
  ...sourceSpecificInstructions(input)
4179
4337
  ] : []
@@ -4184,7 +4342,6 @@ function searchInstructions(input) {
4184
4342
  return [
4185
4343
  "Implement an in-app Algolia search experience.",
4186
4344
  `Build the search UI for ${input.searchUiTarget}.`,
4187
- "Search UI only \u2014 the index is already populated. Do not create or modify ingestion scripts, rake/manage/CLI tasks, migrations, seeders, or other data-loading code, even if the data looks incomplete.",
4188
4345
  ...doc ? [
4189
4346
  "Follow the Algolia SDK reference below for client setup, search UI wiring, and Insights instrumentation \u2014 Insights is required, not optional; prefer the reference over prior knowledge:",
4190
4347
  doc
@@ -4192,15 +4349,9 @@ function searchInstructions(input) {
4192
4349
  "No bundled Algolia SDK reference exists for this stack, so rely on the project's own conventions and Algolia's official client for its language. Do not invent APIs \u2014 keep to the documented search endpoint and its parameters."
4193
4350
  ],
4194
4351
  `Create the search experience as its own component in a new file, following the project's existing component conventions (location, naming, styling approach). Do not write it inline into an existing file.`,
4195
- `Import and render that new component from ${input.searchLocation ? `"${input.searchLocation}"` : "the best shared, always-rendered layout location (e.g. a header/nav component)"} so it is reachable across the app \u2014 at least a working search input and results panel against the target index.`,
4352
+ `Import and render that new component from ${input.searchLocation ? `"${input.searchLocation}"` : "the best shared, always-rendered layout location (e.g. a header/nav component)"} so it is reachable across the app \u2014 at least a working search input and results list against the target index.`,
4196
4353
  "If a search box already exists, replace its usage with an import and render of your new component; remove the old implementation.",
4197
4354
  "When rendering results with an existing shared component (e.g. a card), import and reuse that component rather than inlining its markup \u2014 inlining silently drops the styles and behavior its own file provides.",
4198
- "Results go in a dropdown panel, not a permanently visible list. If the SDK reference above documents an Autocomplete widget for this stack, it already owns panel open/close, Escape, blur, outside click, arrow-key navigation, combobox ARIA, and Insights events \u2014 use it as-is and add no open/close logic, query gating, or ARIA roles of your own, even if it opens on focus before anything is typed. Build it by hand only where the reference has no such widget or calls it headless: then open the panel only while the input is focused with a non-empty query, close it on Escape, blur, and outside click, and move an active-result highlight with Up/Down, Enter to select.",
4199
- "Let the widget own selection: where hits have a detail page, pass its per-index URL option (getURL or the flavor's equivalent) so it navigates on click and on Enter with a result highlighted, and keep your item template or component to content only \u2014 no anchor, no href, no click handler of your own, no stopPropagation. The widget's result element sends the Insights click event, closes the panel, and clears the active option; your own link swallows all three while still navigating, so it looks correct and silently loses click analytics.",
4200
- "Render the zero-results state through the widget's own no-results template or component, so a query matching nothing shows a message rather than an empty panel.",
4201
- "Meet WCAG AA contrast (4.5:1 body text, 3:1 large text/icons) between the panel's text and its own background, and give the input and the active result a focus indicator visible against whatever sits behind it. Style the panel through the widget's class and CSS-variable overrides (or, when hand-built, the app's existing theme tokens) \u2014 never assume a light surface or reuse the surrounding page's colors unchanged inside the panel.",
4202
- "Vendor theme CSS, and any CSS you write against vendor class names, must be global: import the theme from the component's own script module or the app's global stylesheet, and put overrides in a global block (Astro <style is:global>, an unscoped Vue block, a plain global CSS file). Never a framework-scoped style block or a CSS Module \u2014 scoping rewrites the vendor selectors and the widget's runtime DOM carries no scope attribute, so not one rule matches: styling silently does nothing and the build still passes.",
4203
- "The panel takes its width from the input by default, so a small input makes it unreadably narrow: give it a min-width of 320px independent of the input, anchored to the input edge it opens from so widening does not push it off-screen.",
4204
4355
  `Define ${SEARCH_CONFIG_APP_ID}, ${SEARCH_CONFIG_SEARCH_KEY}, and ${SEARCH_CONFIG_INDEX_NAME} as exported constants in a module that fits this project's existing conventions for shared client-side config \u2014 reuse an existing one if it already holds config like this, or add a small new one otherwise. These are PUBLIC values, safe to commit and expose client-side: never read them from an environment variable or a .env* file, and never hardcode them anywhere except in that one module (import them wherever the search client needs them).`,
4205
4356
  `Set ${SEARCH_CONFIG_APP_ID} to "${input.appId}" and ${SEARCH_CONFIG_INDEX_NAME} to "${input.targetIndex}".`,
4206
4357
  input.searchKey ? `Set ${SEARCH_CONFIG_SEARCH_KEY} to "${input.searchKey}".` : `A real search-only key could not be provisioned${input.searchKeyError ? ` (${input.searchKeyError})` : ""} \u2014 set ${SEARCH_CONFIG_SEARCH_KEY} to the placeholder "${SEARCH_KEY_PLACEHOLDER}" and add a prominent TODO for the developer to fill in a real one.`,
@@ -4552,19 +4703,33 @@ ${detail}` : ""}`
4552
4703
  product_area: "AI Wizard"
4553
4704
  });
4554
4705
  }
4555
- const commandMessages = ingestCommand ? [`Ingestion command: ${ingestCommand}`] : [];
4556
- await ctx.requestUserInput({
4557
- prompt: "",
4558
- promptType: "enterToContinue",
4559
- options: [],
4560
- // One message per line: Notices.tsx counts a message as one wrapped
4561
- // line, so an embedded newline overflows the panel's height accounting.
4562
- messages: [
4563
- ingestOutcomeMessage,
4564
- ...ingestFailureDetail?.split("\n").filter((l) => l.trim()) ?? [],
4565
- ...commandMessages
4566
- ]
4567
- });
4706
+ if (ingestScriptRan) {
4707
+ await ctx.requestUserInput({
4708
+ prompt: "Continue",
4709
+ promptType: "notice",
4710
+ options: [],
4711
+ messages: [
4712
+ ingestOutcomeMessage,
4713
+ "View your ingested records in the Algolia dashboard:",
4714
+ "https://dashboard.algolia.com/explorer/browse",
4715
+ "When you're ready to continue, we'll move on to search implementation."
4716
+ ]
4717
+ });
4718
+ } else {
4719
+ const commandMessages = ingestCommand ? [`Ingestion command: ${ingestCommand}`] : [];
4720
+ await ctx.requestUserInput({
4721
+ prompt: "",
4722
+ promptType: "enterToContinue",
4723
+ options: [],
4724
+ // One message per line: Notices.tsx counts a message as one wrapped
4725
+ // line, so an embedded newline overflows the panel's height accounting.
4726
+ messages: [
4727
+ ingestOutcomeMessage,
4728
+ ...ingestFailureDetail?.split("\n").filter((l) => l.trim()) ?? [],
4729
+ ...commandMessages
4730
+ ]
4731
+ });
4732
+ }
4568
4733
  }
4569
4734
  let searchConfigFile;
4570
4735
  if (useCases.includes("search")) {
@@ -5756,7 +5921,7 @@ function App() {
5756
5921
  }
5757
5922
 
5758
5923
  // src/lib/envAppId.ts
5759
- import { readFile as readFile7 } from "node:fs/promises";
5924
+ import { readFile as readFile8 } from "node:fs/promises";
5760
5925
  import { join as join12 } from "node:path";
5761
5926
  var ENV_FILES = [".env", ".env.local"];
5762
5927
  var APP_ID_LINE = /^[ \t]*(?:export[ \t]+)?([A-Z0-9_]*ALGOLIA_APP(?:LICATION)?_ID)[ \t]*=[ \t]*(.*)$/gm;
@@ -5764,7 +5929,7 @@ async function findEnvApplicationId(root = process.cwd()) {
5764
5929
  for (const file of ENV_FILES) {
5765
5930
  let content;
5766
5931
  try {
5767
- content = await readFile7(join12(root, file), "utf8");
5932
+ content = await readFile8(join12(root, file), "utf8");
5768
5933
  } catch (err) {
5769
5934
  if (err.code !== "ENOENT") {
5770
5935
  logger.warn(
@@ -20,20 +20,14 @@ Always install the **latest stable** within the major (use a caret range like `^
20
20
 
21
21
  - Use a **search-only API key** in any browser/client code. It is safe to expose.
22
22
  NEVER ship an admin or any write-capable key to the client.
23
- - Import the Application ID and search-only key from the project's shared client-side
24
- config module as plain exported constants. They are public, safe to commit — never read
25
- them from an env var or `.env*` file, and never hardcode them elsewhere.
23
+ - Read the Application ID and search-only key from **public env vars**, never hardcode
24
+ them inline.
26
25
  - Instantiate the search client **once, outside your components**, and pass a stable
27
26
  reference. Do not inline `algoliasearch(...)` as a prop value — it breaks the
28
27
  client cache and causes re-renders.
29
28
  - For InstantSearch, import the client from `algoliasearch/lite` (smaller bundle and
30
29
  correct types — see `instantsearch-setup.md`).
31
30
  - Always enable **Insights** on the InstantSearch root with `insights: true`. Without it there are no click/conversion events, so many Algolia features will not work as expected.
32
- - Vendor CSS must be **global**: import an InstantSearch theme from a JS/TS module or the
33
- app's global stylesheet and keep `.ais-*` overrides in a global block. In a scoped style
34
- block (Astro `<style>`, Vue `<style scoped>`, Svelte `<style>`) or a CSS Module, scoping
35
- rewrites the selectors, the widget's runtime DOM carries no scope attribute, and the
36
- styling silently does nothing while the build passes.
37
31
 
38
32
  ## Files
39
33
 
@@ -20,18 +20,10 @@ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
20
20
 
21
21
  ## Vanilla (`instantsearch.js` v4)
22
22
 
23
- Use the `autocomplete` widget for the results panel. It owns focus, blur, outside-click
24
- dismissal, Up/Down navigation with wrap-around, Enter/Escape/Tab, and the combobox ARIA
25
- (`combobox`, `aria-expanded`, `aria-activedescendant`) — add no open/close logic or ARIA
26
- roles of your own.
27
-
28
- Needs `instantsearch.js` **4.110.0+**. If an older 4.x is pinned, upgrade within `^4`
29
- rather than switching widgets.
30
-
31
23
  ```js
32
24
  import { liteClient as algoliasearch } from 'algoliasearch/lite'
33
25
  import instantsearch from 'instantsearch.js'
34
- import { autocomplete } from 'instantsearch.js/es/widgets'
26
+ import { searchBox, hits } from 'instantsearch.js/es/widgets'
35
27
  import {
36
28
  ALGOLIA_APP_ID,
37
29
  ALGOLIA_SEARCH_API_KEY,
@@ -47,140 +39,24 @@ const search = instantsearch({
47
39
  })
48
40
 
49
41
  search.addWidgets([
50
- autocomplete({
51
- container: '#autocomplete',
52
- placeholder: 'Search...',
53
- indices: [
54
- {
55
- indexName: ALGOLIA_INDEX_NAME,
56
- getURL: (item) => `/products/${item.handle}`,
57
- templates: {
58
- item({ item, onSelect }, { html, components }) {
59
- return html`<div onClick=${onSelect}>
60
- ${components.Highlight({ hit: item, attribute: 'name' })}
61
- </div>`
62
- },
63
- noResults(_, { html }) {
64
- return html`<span>No results</span>`
65
- },
66
- },
67
- },
68
- ],
69
- }),
42
+ searchBox({ container: '#searchbox' }),
43
+ hits({ container: '#hits' }),
70
44
  ])
71
45
 
72
46
  search.start()
73
47
  ```
74
48
 
75
- ### Navigation is `getURL`'s job, not the template's
76
-
77
- - `indices[].getURL` — `(item) => string`, a route that already exists; don't invent one.
78
- It navigates on click **and** on Enter over a highlighted item, and records the click
79
- event. Without it, Enter on a highlight does nothing.
80
- - The `item` template renders content only: **no `<a>`, no `href`, no click handler of
81
- your own, never `stopPropagation`.** The widget's `<li>` owns the click — it sends the
82
- Insights `click`, closes the panel, clears the active option. An anchor wrapper swallows
83
- all three while still navigating: looks correct, click analytics never fire, panel stays
84
- open behind the new page.
85
- - `templates.noResults` on every index, or a query matching nothing renders an empty box.
86
- - `getSearchPageURL` sends Enter with nothing highlighted to a full results page; pass it
87
- only if the app has one. `.getQuery` is optional. Leave `showQuerySuggestions`,
88
- `showPromptSuggestions`, `showRecent`, and `aiMode` unset — each needs an index or
89
- feature (query suggestions, recent-search storage, AI chat) this project lacks.
90
- - TypeScript: type records through the generic, not casts inside templates —
91
- `type ProductHit = Hit<{ title: string; handle: string }>` (`Hit` from
92
- `instantsearch.js`), then `autocomplete<ProductHit>({ ... })` types `item`, `getURL`,
93
- and the templates.
94
-
95
- ### Styling the panel
96
-
97
- The widget ships no CSS. Install `instantsearch.css` and import a theme once, from the
98
- component's own JS/TS module or the app's global stylesheet:
99
-
100
- ```js
101
- import 'instantsearch.css/themes/satellite.css'
102
- ```
103
-
104
- The theme and your `.ais-*` overrides must be **global**: never a CSS `@import` inside a
105
- framework-scoped style block (Astro `<style>`, Vue `<style scoped>`, Svelte `<style>`) or
106
- a CSS Module. Scoping rewrites every selector
107
- (`.ais-AutocompleteInput[data-astro-cid-abc123]`) and the widget builds its DOM at runtime
108
- with no scope attribute, so not one rule matches — unstyled UI, no error, build passes.
109
- Use `<style is:global>` (Astro), an unscoped block or `:deep()` (Vue), or plain global CSS.
110
-
111
- The theme ships a dark palette behind `:root[data-theme=dark], .dark`: in a dark app set
112
- `data-theme="dark"` on `<html>` or render under a `.dark` ancestor, then adjust — don't
113
- redefine the light palette by hand.
114
-
115
- Palette lives in `--ais-*` custom properties on `:root` as **RGB triplets consumed through
116
- `rgba()`**, not CSS colors — override with triplets or every widget color breaks:
117
-
118
- ```css
119
- :root {
120
- --ais-background-color-rgb: 24, 24, 27; /* not #18181b */
121
- --ais-text-color-rgb: 244, 244, 245;
122
- --ais-primary-color-rgb: 129, 140, 248;
123
- }
124
- ```
125
-
126
- Everything else is plain CSS against the widget's classes, or `cssClasses` /
127
- `indices[].cssClasses` to add your own names to the same elements. Who owns what:
128
-
129
- - `.ais-Autocomplete` — root. Its **container** sets the widget's width; don't size the
130
- widget from the input.
131
- - `.ais-AutocompleteForm` — visible frame: background, border, radius, focus ring
132
- (`:focus-within`). The input's pill goes here.
133
- - `.ais-AutocompleteInput` — text only; theme sets `border: 0`, `background: none`,
134
- `width: 100%`. A border or width here leaves the form's frame drawn around it and
135
- changes no widths.
136
- - `.ais-AutocompletePanel` (+ `--open`), `.ais-AutocompletePanelLayout` — dropdown surface
137
- and its scroll container.
138
- - `.ais-AutocompleteIndex`, `-IndexHeader`, `-IndexList`, `-IndexItem`, `-IndexNoResults`
139
- — one index's results; the active item carries `aria-selected="true"`.
140
- - `.ais-AutocompleteItemWrapper`, `-ItemContent`, `-ItemIcon` — inside one result.
141
- - `.ais-AutocompleteSubmitButton`, `-ClearButton`, `-LoadingIndicator`, `-Label` — form
142
- controls.
143
- - `.ais-AutocompleteDetached*` — detached mode (below).
144
-
145
- Style nothing outside that list: `.ais-SearchBox-*` belongs to `searchBox` and does
146
- nothing here. Verify names against the installed version:
147
- `grep -o 'ais-Autocomplete[A-Za-z-]*' node_modules/instantsearch.css/themes/satellite.css | sort -u`.
148
-
149
- The theme's panel is `position: absolute; width: 100%`, so it inherits the input
150
- container's width — a small input yields an unreadably narrow panel. Give it a min-width
151
- and anchor it to the edge it opens from, or it grows off-screen:
152
-
153
- ```css
154
- .ais-AutocompletePanel {
155
- min-width: 320px;
156
- left: 0; /* right: 0; left: auto for an input aligned to the right */
157
- }
158
- ```
159
-
160
- Below `(max-width: 680px)` the widget goes detached (fullscreen): the inline form becomes
161
- a `.ais-AutocompleteDetachedSearchButton`, form and results move into an overlay
162
- (`.ais-AutocompleteDetachedOverlay`, `.ais-AutocompleteDetachedContainer`,
163
- `.ais-AutocompleteDetachedFormContainer`). So panel-width rules apply only above that
164
- breakpoint, and styling the inline form leaves the mobile trigger button on the theme's
165
- look unless you style it too. Change it with `detachedMediaQuery` or
166
- `--ais-autocomplete-detached-media-query`; pass `''` to disable detached mode entirely.
167
-
168
49
  ## Insights (click analytics) — always enable it
169
50
 
170
51
  The `insights` option turns on the Insights middleware (`instantsearch.js` v4.55+),
171
52
  which adds `clickAnalytics: true` to every query and keeps the `userToken` in sync
172
53
  between search and event calls. Never set `clickAnalytics` or `userToken` on the `configure` widget by hand.
173
54
 
174
- Use `insights: true` and nothing else. The middleware loads `search-insights` from the
175
- jsDelivr CDN itself; do not install the package, pass `insightsClient`, or declare
176
- `window.aa`.
55
+ Use `insights: true`. The middleware loads `search-insights` from the jsDelivr CDN
56
+ itself; do not install the package or pass `insightsClient`.
177
57
 
178
- Everything below is a **conditional fallback** — apply one only where its condition
179
- actually holds here (a CSP you can point at, a user id the app already has). Otherwise
180
- `insights: true` is the whole implementation.
181
-
182
- Only if the project has a Content-Security-Policy blocking jsDelivr (the CDN script then
183
- cannot load): install `search-insights` from npm and pass its default export as
58
+ If a Content-Security-Policy blocks jsDelivr, the CDN script cannot load. In that
59
+ case, install `search-insights` from npm and pass its default export as
184
60
  `insightsClient` in the `insights` option:
185
61
 
186
62
  ```js
@@ -214,16 +90,27 @@ insights: {
214
90
 
215
91
  ### Click and conversion events
216
92
 
217
- With `insights: true`, `autocomplete` sends `view` (on render) and `click` (from the
218
- result element's own handler) itself. Its item template gets `{ item, onSelect }` and
219
- **no `sendEvent`** — nothing to wire. Call `onSelect` for the widget's selection behavior
220
- (close the panel, record a recent search); the click event fires either way.
93
+ The `hits` and `infiniteHits` widgets send `view` and `click` events on their own. A
94
+ custom `item` template whose click target is your own element gets `sendEvent` as the
95
+ second argument:
221
96
 
222
- `hits` and `infiniteHits` behave the same; only a custom `item` template whose click
223
- target is your own element needs wiring — it gets `sendEvent` as the second argument.
97
+ ```js
98
+ hits({
99
+ container: '#hits',
100
+ templates: {
101
+ item(hit, { html, sendEvent }) {
102
+ return html`<a
103
+ href="${hit.url}"
104
+ onClick="${() => sendEvent('click', hit, 'Hit Clicked')}"
105
+ >${hit.name}</a
106
+ >`
107
+ },
108
+ },
109
+ })
110
+ ```
224
111
 
225
- Conversions are business actions (add to cart, signup, booking) away from the results
226
- panel: wire them at the real action, in a component with its own `sendEvent`:
112
+ Conversions are business actions (add to cart, signup, booking), so wire them at the
113
+ real action, not at render:
227
114
 
228
115
  ```js
229
116
  sendEvent('conversion', hit, 'Product Added To Cart', {
@@ -20,17 +20,9 @@ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
20
20
 
21
21
  ## React (`react-instantsearch` v7)
22
22
 
23
- Use the `Autocomplete` widget for the results panel. It owns focus, blur, outside-click
24
- dismissal, Up/Down navigation with wrap-around, Enter/Escape/Tab, and the combobox ARIA
25
- (`combobox` on the input, `aria-expanded`, `aria-activedescendant`) — add no open/close
26
- logic or ARIA roles of your own.
27
-
28
- Needs `react-instantsearch` **7.18.0+**. If an older 7.x is pinned, upgrade within `^7`
29
- rather than switching widgets.
30
-
31
23
  ```tsx
32
24
  import { liteClient as algoliasearch } from 'algoliasearch/lite'
33
- import { InstantSearch, Autocomplete } from 'react-instantsearch'
25
+ import { InstantSearch, SearchBox, Hits } from 'react-instantsearch'
34
26
  import {
35
27
  ALGOLIA_APP_ID,
36
28
  ALGOLIA_SEARCH_API_KEY,
@@ -46,117 +38,13 @@ export function Search() {
46
38
  indexName={ALGOLIA_INDEX_NAME}
47
39
  insights={true}
48
40
  >
49
- <Autocomplete
50
- placeholder="Search..."
51
- indices={[
52
- {
53
- indexName: ALGOLIA_INDEX_NAME,
54
- getURL: (item) => `/products/${item.handle}`,
55
- itemComponent: ({ item, onSelect }) => (
56
- <div onClick={onSelect}>{item.name}</div>
57
- ),
58
- noResultsComponent: () => <span>No results</span>,
59
- },
60
- ]}
61
- />
41
+ <SearchBox />
42
+ <Hits />
62
43
  </InstantSearch>
63
44
  )
64
45
  }
65
46
  ```
66
47
 
67
- ### Navigation is `getURL`'s job, not the item component's
68
-
69
- - `indices[].getURL` — `(item) => string`, a route that already exists; don't invent one.
70
- It navigates on click **and** on Enter over a highlighted item, and records the click
71
- event. Without it, Enter on a highlight does nothing.
72
- - `itemComponent` renders content only: **no `<a>`, no `href`, no `onClick` beyond the
73
- `onSelect` it is handed, never `stopPropagation`.** The widget's `<li>` owns the click —
74
- it sends the Insights `click`, closes the panel, clears the active option. Wrapping the
75
- item in a link or a router `<Link>` swallows all three while still navigating: looks
76
- correct, click analytics never fire, panel stays open behind the new page.
77
- - `noResultsComponent` on every index, or a query matching nothing renders an empty box.
78
- - `getSearchPageURL` sends Enter with nothing highlighted to a full results page; pass it
79
- only if the app has one. `.getQuery` is optional. Leave `showQuerySuggestions`,
80
- `showPromptSuggestions`, `showRecent`, and `aiMode` unset — each needs an index or
81
- feature (query suggestions, recent-search storage, AI chat) this project lacks.
82
- - TypeScript: type records through the generic, not casts inside `itemComponent` —
83
- `type ProductHit = Hit<{ title: string; handle: string }>` (`Hit` from
84
- `instantsearch.js`), then `<Autocomplete<ProductHit> ... />` types `item`, `getURL`, and
85
- `itemComponent`.
86
-
87
- ### Styling the panel
88
-
89
- The widget ships no CSS. Install `instantsearch.css` and import a theme once, from the
90
- component module or the app's global stylesheet:
91
-
92
- ```tsx
93
- import 'instantsearch.css/themes/satellite.css'
94
- ```
95
-
96
- The theme and your `.ais-*` overrides must be **global**: never route either through a CSS
97
- Module, a scoped style block (Astro `<style>`, Vue `<style scoped>`, Svelte `<style>`), or
98
- any styled-components-style wrapper that rewrites selectors. Scoping renames every
99
- selector and the widget builds its DOM at runtime, so nothing matches — unstyled UI, no
100
- error, build passes. Keep them in a global stylesheet, or add your own class names via
101
- `classNames` and style those.
102
-
103
- The theme ships a dark palette behind `:root[data-theme=dark], .dark`: in a dark app set
104
- `data-theme="dark"` on `<html>` or render under a `.dark` ancestor, then adjust — don't
105
- redefine the light palette by hand.
106
-
107
- Palette lives in `--ais-*` custom properties on `:root` as **RGB triplets consumed through
108
- `rgba()`**, not CSS colors — override with triplets or every widget color breaks:
109
-
110
- ```css
111
- :root {
112
- --ais-background-color-rgb: 24, 24, 27; /* not #18181b */
113
- --ais-text-color-rgb: 244, 244, 245;
114
- --ais-primary-color-rgb: 129, 140, 248;
115
- }
116
- ```
117
-
118
- Everything else is plain CSS against the widget's classes, or the `classNames` /
119
- `indices[].classNames` props to add your own names to the same elements. Who owns what:
120
-
121
- - `.ais-Autocomplete` — root. Its **container** sets the widget's width; don't size the
122
- widget from the input.
123
- - `.ais-AutocompleteForm` — visible frame: background, border, radius, focus ring
124
- (`:focus-within`). The input's pill goes here.
125
- - `.ais-AutocompleteInput` — text only; theme sets `border: 0`, `background: none`,
126
- `width: 100%`. A border or width here leaves the form's frame drawn around it and
127
- changes no widths.
128
- - `.ais-AutocompletePanel` (+ `--open`), `.ais-AutocompletePanelLayout` — dropdown surface
129
- and its scroll container.
130
- - `.ais-AutocompleteIndex`, `-IndexHeader`, `-IndexList`, `-IndexItem`, `-IndexNoResults`
131
- — one index's results; the active item carries `aria-selected="true"`.
132
- - `.ais-AutocompleteItemWrapper`, `-ItemContent`, `-ItemIcon` — inside one result.
133
- - `.ais-AutocompleteSubmitButton`, `-ClearButton`, `-LoadingIndicator`, `-Label` — form
134
- controls.
135
- - `.ais-AutocompleteDetached*` — detached mode (below).
136
-
137
- Style nothing outside that list: `.ais-SearchBox-*` belongs to `SearchBox` and does
138
- nothing here. Verify names against the installed version:
139
- `grep -o 'ais-Autocomplete[A-Za-z-]*' node_modules/instantsearch.css/themes/satellite.css | sort -u`.
140
-
141
- The theme's panel is `position: absolute; width: 100%`, so it inherits the input
142
- container's width — a small input yields an unreadably narrow panel. Give it a min-width
143
- and anchor it to the edge it opens from, or it grows off-screen:
144
-
145
- ```css
146
- .ais-AutocompletePanel {
147
- min-width: 320px;
148
- left: 0; /* right: 0; left: auto for an input aligned to the right */
149
- }
150
- ```
151
-
152
- Below `(max-width: 680px)` the widget goes detached (fullscreen): the inline form becomes
153
- a `.ais-AutocompleteDetachedSearchButton`, form and results move into an overlay
154
- (`.ais-AutocompleteDetachedOverlay`, `.ais-AutocompleteDetachedContainer`,
155
- `.ais-AutocompleteDetachedFormContainer`). So panel-width rules apply only above that
156
- breakpoint, and styling the inline form leaves the mobile trigger button on the theme's
157
- look unless you style it too. Change it with `detachedMediaQuery` or
158
- `--ais-autocomplete-detached-media-query`; pass `''` to disable detached mode entirely.
159
-
160
48
  Do not inline `searchClient={algoliasearch(...)}` — keep the stable reference above.
161
49
 
162
50
  ## Insights (click analytics) — always enable it
@@ -165,28 +53,17 @@ The `insights` prop turns on the Insights middleware, which adds
165
53
  `clickAnalytics: true` to every query and keeps the `userToken` in sync between search
166
54
  and event calls. Never set `clickAnalytics` or `userToken` on `<Configure>` by hand.
167
55
 
168
- Use `insights={true}` and nothing else. The middleware loads `search-insights` from the
169
- jsDelivr CDN itself; do not install the package, pass `insightsClient`, or declare
170
- `window.aa`.
56
+ Use `insights={true}`. The middleware loads `search-insights` from the jsDelivr CDN
57
+ itself; do not install the package or pass `insightsClient`.
171
58
 
172
- Everything below is a **conditional fallback** — apply one only where its condition
173
- actually holds here (a CSP you can point at, a user id the app already has). Otherwise
174
- `insights={true}` is the whole implementation.
175
-
176
- Only if the project has a Content-Security-Policy blocking jsDelivr (the CDN script then
177
- cannot load): install `search-insights` from npm and pass its default export as
59
+ If a Content-Security-Policy blocks jsDelivr, the CDN script cannot load. In that
60
+ case, install `search-insights` from npm and pass its default export as
178
61
  `insightsClient`:
179
62
 
180
63
  ```tsx
181
64
  import aa from 'search-insights'
182
65
 
183
- export function Search() {
184
- return (
185
- <InstantSearch insights={{ insightsClient: aa }}>
186
- {/* widgets */}
187
- </InstantSearch>
188
- )
189
- }
66
+ <InstantSearch insights={{ insightsClient: aa }} />
190
67
  ```
191
68
 
192
69
  The middleware calls `aa('init', ...)` itself with the app ID and search key, so no
@@ -206,13 +83,8 @@ policy allows it:
206
83
 
207
84
  ### Click and conversion events
208
85
 
209
- With `insights={true}`, `<Autocomplete>` sends `view` (on render) and `click` (from the
210
- result element's own handler) itself. Its `itemComponent` receives
211
- `{ item, onSelect, onApply }` and **no `sendEvent`** — nothing to wire. Call `onSelect`
212
- for the widget's selection behavior (close the panel, record a recent search); the click
213
- event fires either way.
214
-
215
- `<Hits>` and `<InfiniteHits>` behave the same way. Two cases there need explicit wiring:
86
+ `<Hits>` and `<InfiniteHits>` send `view` and `click` events on their own. Two cases
87
+ need explicit wiring:
216
88
 
217
89
  - A custom `hitComponent` whose click target is your own element — it receives a
218
90
  `sendEvent` prop.
@@ -20,68 +20,19 @@ const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
20
20
 
21
21
  ## Vue (`vue-instantsearch` v4)
22
22
 
23
- Use `ais-autocomplete` for the query and results state. It is **headless**: its default
24
- slot provides only `currentRefinement`, `indices`, and `refine` — it renders nothing and
25
- handles no focus, blur, keyboard, or ARIA. Build all of it yourself: panel closed until
26
- the input is focused and has a query; close on Escape, blur, or outside click; active-option
27
- highlight on Up/Down with Enter to select; combobox ARIA on the input; click events via
28
- each index's `sendEvent`.
29
-
30
23
  ```vue
31
24
  <template>
32
- <div ref="containerRef" class="search" @focusout="onFocusOut">
33
- <ais-instant-search
34
- :search-client="searchClient"
35
- :index-name="ALGOLIA_INDEX_NAME"
36
- :insights="true"
37
- >
38
- <ais-autocomplete>
39
- <template v-slot="{ currentRefinement, indices, refine }">
40
- <input
41
- type="search"
42
- role="combobox"
43
- aria-autocomplete="list"
44
- aria-controls="search-panel"
45
- :aria-expanded="isOpen(currentRefinement)"
46
- :aria-activedescendant="
47
- active >= 0 ? `search-option-${active}` : undefined
48
- "
49
- :value="currentRefinement"
50
- @focus="open = true"
51
- @input="onInput(refine, $event)"
52
- @keydown.down.prevent="move(1, indices)"
53
- @keydown.up.prevent="move(-1, indices)"
54
- @keydown.enter.prevent="select(indices, active)"
55
- @keydown.esc="close"
56
- />
57
- <ul
58
- v-show="isOpen(currentRefinement)"
59
- id="search-panel"
60
- class="search-panel"
61
- role="listbox"
62
- aria-label="Search results"
63
- >
64
- <li
65
- v-for="(hit, i) in indices[0]?.hits ?? []"
66
- :id="`search-option-${i}`"
67
- :key="hit.objectID"
68
- role="option"
69
- :aria-selected="i === active"
70
- :class="{ 'is-active': i === active }"
71
- @mousedown.prevent="select(indices, i)"
72
- @mousemove="active = i"
73
- >
74
- <ais-highlight attribute="name" :hit="hit" />
75
- </li>
76
- </ul>
77
- </template>
78
- </ais-autocomplete>
79
- </ais-instant-search>
80
- </div>
25
+ <ais-instant-search
26
+ :search-client="searchClient"
27
+ :index-name="ALGOLIA_INDEX_NAME"
28
+ :insights="true"
29
+ >
30
+ <ais-search-box />
31
+ <ais-hits />
32
+ </ais-instant-search>
81
33
  </template>
82
34
 
83
35
  <script setup>
84
- import { ref } from 'vue'
85
36
  import { liteClient as algoliasearch } from 'algoliasearch/lite'
86
37
  import {
87
38
  ALGOLIA_APP_ID,
@@ -90,71 +41,9 @@ import {
90
41
  } from '<the project's shared config module>'
91
42
 
92
43
  const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_API_KEY)
93
- const open = ref(false)
94
- const active = ref(-1)
95
- const containerRef = ref(null)
96
-
97
- function isOpen(currentRefinement) {
98
- return open.value && Boolean(currentRefinement)
99
- }
100
-
101
- function onInput(refine, event) {
102
- open.value = true
103
- active.value = -1
104
- refine(event.currentTarget.value)
105
- }
106
-
107
- function move(delta, indices) {
108
- const hits = indices[0]?.hits ?? []
109
- if (!hits.length) return
110
- open.value = true
111
- const next = active.value + delta
112
- active.value = next < 0 ? hits.length - 1 : next % hits.length
113
- }
114
-
115
- function select(indices, index) {
116
- const hit = indices[0]?.hits?.[index]
117
- if (!hit) return
118
- indices[0].sendEvent('click', hit, 'Hit Clicked')
119
- close()
120
- // TODO: navigate to this hit's page, where the app has one
121
- }
122
-
123
- function close() {
124
- open.value = false
125
- active.value = -1
126
- }
127
-
128
- function onFocusOut(event) {
129
- if (!containerRef.value?.contains(event.relatedTarget)) close()
130
- }
131
44
  </script>
132
45
  ```
133
46
 
134
- Details that break accessibility if changed:
135
-
136
- - `role="combobox"` on the `input`, not a wrapper — with `aria-controls` at the popup's
137
- `id` and `aria-activedescendant` at the active option's `id`. Focus stays on the input
138
- while the arrows move the highlight.
139
- - The `role="listbox"` element must be the direct parent of the `role="option"` elements;
140
- a `ul` inside a `listbox` div, or options one level deeper, breaks the a11y tree.
141
- - `v-show`, not `v-if`, so the `aria-activedescendant` target stays in the DOM.
142
- - `@mousedown.prevent` on an option, not `@click` — selection must land before `focusout`
143
- closes the panel.
144
- - `focusout` on the wrapper is the outside-click dismissal (`event.relatedTarget` is
145
- `null` for a click on non-focusable chrome). Don't add a `document` click listener on
146
- top — it fights `@mousedown.prevent`.
147
- - `.is-active` needs a visible style, not just `aria-selected` — it is a keyboard user's
148
- only focus feedback, since focus never leaves the input.
149
-
150
- The panel is your own markup with no widget theme to inherit: give `.search-panel` a
151
- background from the app's theme tokens (never transparent over page content), text
152
- clearing 4.5:1 against it, `position: absolute` with `min-width: 320px` anchored to the
153
- input edge it opens from, and a `max-height` with `overflow-y: auto`.
154
-
155
- `indices` supports federated search across indices; this project has one, so `indices[0]`
156
- is always the one to read.
157
-
158
47
  Register the widgets via the InstantSearch Vue plugin in your app entry:
159
48
 
160
49
  ```js
@@ -168,15 +57,12 @@ The `insights` prop turns on the Insights middleware, which adds
168
57
  `clickAnalytics: true` to every query and keeps the `userToken` in sync between search
169
58
  and event calls. Never set `clickAnalytics` or `userToken` on `ais-configure` by hand.
170
59
 
171
- Use `:insights="true"` and nothing else. The middleware loads `search-insights` from the
172
- jsDelivr CDN itself; do not install the package, pass `insightsClient`, or declare `window.aa`.
60
+ Use `:insights="true"`. The middleware loads `search-insights` from the jsDelivr CDN itself; do not install
61
+ the package or pass `insightsClient`.
173
62
 
174
- Everything below is a **conditional fallback** — apply one only where its condition actually holds
175
- here (a CSP you can point at, a user id the app already has). Otherwise `:insights="true"` is the
176
- whole implementation.
177
-
178
- Only if the project has a Content-Security-Policy blocking jsDelivr (the CDN script then cannot
179
- load): install `search-insights` from npm and pass its default export as `insightsClient`:
63
+ If a Content-Security-Policy blocks jsDelivr, the CDN script cannot load. In that
64
+ case, install `search-insights` from npm and pass its default export as
65
+ `insightsClient`:
180
66
 
181
67
  ```js
182
68
  import aa from 'search-insights'
@@ -199,18 +85,19 @@ const insights = { insightsInitParams: { useCookie: true } } // then :insights="
199
85
 
200
86
  ### Click and conversion events
201
87
 
202
- `ais-autocomplete` wraps the same connector as the other flavors, so `view` fires on its
203
- own when results render. `click` does not — the option markup is yours: call the index's
204
- `sendEvent` from your own option handler, as in the setup example above.
88
+ `ais-hits` sends `view` and `click` events on its own. A custom `item` slot whose
89
+ click target is your own element needs the slot's `sendEvent`:
205
90
 
206
91
  ```vue
207
- <li @mousedown.prevent="indices[0].sendEvent('click', hit, 'Hit Clicked')">
92
+ <ais-hits>
93
+ <template v-slot:item="{ item, sendEvent }">
94
+ <a :href="item.url" @click="sendEvent('click', item, 'Hit Clicked')">
95
+ {{ item.name }}
96
+ </a>
97
+ </template>
98
+ </ais-hits>
208
99
  ```
209
100
 
210
- `ais-hits` (if the app also has a full results page) sends both itself; only a custom
211
- `item` slot whose click target is your own element needs wiring — that slot provides
212
- `sendEvent`.
213
-
214
101
  Conversions are business actions (add to cart, signup, booking), so wire them at the
215
102
  real action, not at render:
216
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@algolia/wizard",
3
- "version": "0.45.0-rc.141.320",
3
+ "version": "0.46.0-rc.136.345",
4
4
  "description": "Magically implement Algolia functionality in your codebase",
5
5
  "type": "module",
6
6
  "engines": {