@escalay/kit 0.0.0-stage → 1.7.0

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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/cli.mjs +432 -0
  4. package/docs/STANDARD.md +342 -0
  5. package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
  6. package/docs/decisions/0002-node-and-workers.md +45 -0
  7. package/package.json +58 -3
  8. package/registry.json +512 -0
  9. package/src/a2a/README.md +25 -0
  10. package/src/a2a/index.ts +262 -0
  11. package/src/boards/README.md +35 -0
  12. package/src/boards/handlers.ts +87 -0
  13. package/src/boards/index.ts +64 -0
  14. package/src/boards/refs.ts +23 -0
  15. package/src/boards/repo.ts +50 -0
  16. package/src/boards/routes.ts +83 -0
  17. package/src/boards/schema.ts +28 -0
  18. package/src/boards/tools.ts +76 -0
  19. package/src/browser/README.md +31 -0
  20. package/src/browser/boards-client.ts +74 -0
  21. package/src/browser/boards-react.ts +73 -0
  22. package/src/browser/search-client.ts +258 -0
  23. package/src/browser/search-react.ts +116 -0
  24. package/src/browser/vector-search-block.tsx +94 -0
  25. package/src/core/README.md +37 -0
  26. package/src/core/api.ts +239 -0
  27. package/src/core/auth.ts +147 -0
  28. package/src/core/cache.ts +53 -0
  29. package/src/core/discovery.ts +315 -0
  30. package/src/core/guards.ts +109 -0
  31. package/src/core/hal.ts +63 -0
  32. package/src/core/kv.ts +29 -0
  33. package/src/core/problem.ts +124 -0
  34. package/src/db/README.md +39 -0
  35. package/src/db/guards.ts +61 -0
  36. package/src/db/index.ts +122 -0
  37. package/src/guide/README.md +38 -0
  38. package/src/guide/guide.ts +152 -0
  39. package/src/guide/reference.ts +204 -0
  40. package/src/index.ts +15 -0
  41. package/src/mcp/README.md +34 -0
  42. package/src/mcp/index.ts +251 -0
  43. package/src/oauth/README.md +29 -0
  44. package/src/oauth/index.ts +439 -0
  45. package/src/oauth/schema.ts +7 -0
  46. package/src/oauth/store.ts +34 -0
  47. package/src/pagination/README.md +31 -0
  48. package/src/pagination/index.ts +65 -0
  49. package/src/testing/README.md +31 -0
  50. package/src/testing/index.ts +162 -0
  51. package/src/vector-search/README.md +27 -0
  52. package/src/vector-search/index.ts +367 -0
  53. package/src/vectors/README.md +30 -0
  54. package/src/vectors/index.ts +326 -0
  55. package/src/web/README.md +21 -0
  56. package/src/web/index.ts +100 -0
  57. package/templates/app/AGENTS.md +41 -0
  58. package/templates/app/CLAUDE.md +3 -0
  59. package/templates/app/Dockerfile +12 -0
  60. package/templates/app/container/entrypoint.sh +7 -0
  61. package/templates/app/gitignore +4 -0
  62. package/templates/app/guide/llms.txt +11 -0
  63. package/templates/app/pnpm-workspace.yaml +5 -0
  64. package/templates/app/scripts/guide.ts +8 -0
  65. package/templates/app/src/app.ts +39 -0
  66. package/templates/app/src/features/index.ts +30 -0
  67. package/templates/app/src/server.ts +14 -0
  68. package/templates/app/test/api.test.ts +34 -0
  69. package/templates/app/tsconfig.json +16 -0
  70. package/templates/feature/feature.test.ts +19 -0
  71. package/templates/feature/handlers.ts +43 -0
  72. package/templates/feature/index.ts +34 -0
  73. package/templates/feature/repo.ts +24 -0
  74. package/templates/feature/routes.ts +43 -0
  75. package/templates/feature/schema.ts +14 -0
  76. package/templates/feature/tools.ts +46 -0
  77. package/templates/runtime-cloudflare/dev.vars.example +3 -0
  78. package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
  79. package/templates/runtime-cloudflare/src/worker.ts +19 -0
  80. package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
  81. package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
  82. package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
  83. package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
  84. package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
  85. package/templates/runtime-cloudflare/vitest.config.ts +23 -0
  86. package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
@@ -0,0 +1,76 @@
1
+ /** The board routes as MCP tools, so an agent can keep what it finds for the owner. */
2
+ import { z } from '@hono/zod-openapi'
3
+ import { mcpTool, type McpTool } from '../mcp/index.js'
4
+
5
+ type BoardJson = { id: string; name: string; items: number; description?: string | null; _embedded?: { items: { ref: string; note: string | null; item: unknown }[] } }
6
+
7
+ /**
8
+ * The MCP tools for the same routes, so an agent can keep what it finds for the owner: list, make, open, save to and
9
+ * take off boards (and suggestions, when the app has them).
10
+ *
11
+ * @example
12
+ * boardTools({ noun: 'recipes', label: (recipe) => `${recipe.title} (${recipe.year})`, suggestions: true })
13
+ */
14
+ export const boardTools = ({ path = '/api/boards', noun, label, suggestions = false }: { path?: string; noun: string; label: (item: any) => string; suggestions?: boolean }): McpTool[] => {
15
+ const line = (board: BoardJson) => `${board.name} · ${board.items} ${noun} · id ${board.id}`
16
+ const tools: McpTool[] = [
17
+ mcpTool({
18
+ name: 'list_boards',
19
+ title: 'The owner’s boards',
20
+ description: `Every board (a named collection of ${noun} for a project) with how much is on it; give \`item\` to see which boards hold one.`,
21
+ input: z.object({ item: z.string().optional().describe('Only boards holding this ref.') }),
22
+ annotations: { readOnlyHint: true, openWorldHint: false },
23
+ call: ({ item }) => ({ path, query: item ? { item } : {} }),
24
+ summarize: (json: { _embedded: { boards: BoardJson[] } }) => json._embedded.boards.map(line).join('\n') || 'No boards yet. create_board makes one.',
25
+ }),
26
+ mcpTool({
27
+ name: 'create_board',
28
+ title: 'Make a board',
29
+ description: `A new, empty board to collect ${noun} for a project. Returns its id.`,
30
+ input: z.object({ name: z.string().min(1).describe('A short name.'), description: z.string().optional().describe('What it is for.') }),
31
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
32
+ call: (body) => ({ method: 'POST', path, body }),
33
+ summarize: (json: BoardJson) => `Made ${line(json)}`,
34
+ }),
35
+ mcpTool({
36
+ name: 'get_board',
37
+ title: 'Open a board',
38
+ description: `A board and the ${noun} on it, newest first, with their notes.`,
39
+ input: z.object({ id: z.string().describe('The board’s id.') }),
40
+ annotations: { readOnlyHint: true, openWorldHint: false },
41
+ call: ({ id }) => ({ path: `${path}/${encodeURIComponent(id)}` }),
42
+ summarize: (json: BoardJson) => [line(json), ...(json._embedded?.items ?? []).map((entry) => `- ${entry.item ? label(entry.item) : `${entry.ref} (no longer in the catalog)`}${entry.note ? ` — ${entry.note}` : ''}`)].join('\n'),
43
+ }),
44
+ mcpTool({
45
+ name: 'save_to_board',
46
+ title: 'Save to a board',
47
+ description: `Puts one of the ${noun} on a board, with an optional note on why; saving it again only changes the note.`,
48
+ input: z.object({ id: z.string().describe('The board’s id.'), ref: z.string().describe('The item’s ref, from a search result (e.g. its id).'), note: z.string().optional().describe('Why it belongs here.') }),
49
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
50
+ call: ({ id, ref, note }) => ({ method: 'PUT', path: `${path}/${encodeURIComponent(id)}/items/${encodeURIComponent(ref)}`, body: note ? { note } : {} }),
51
+ summarize: (json: BoardJson) => `Saved. ${line(json)}`,
52
+ }),
53
+ mcpTool({
54
+ name: 'remove_from_board',
55
+ title: 'Take off a board',
56
+ description: `Takes one of the ${noun} off a board.`,
57
+ input: z.object({ id: z.string().describe('The board’s id.'), ref: z.string().describe('The item’s ref.') }),
58
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
59
+ call: ({ id, ref }) => ({ method: 'DELETE', path: `${path}/${encodeURIComponent(id)}/items/${encodeURIComponent(ref)}` }),
60
+ summarize: () => 'Removed.',
61
+ }),
62
+ ]
63
+ if (suggestions)
64
+ tools.push(
65
+ mcpTool({
66
+ name: 'board_suggestions',
67
+ title: 'More like a board',
68
+ description: `${noun[0]!.toUpperCase()}${noun.slice(1)} like the ones on a board, best first, leaving out what is already on it.`,
69
+ input: z.object({ id: z.string().describe('The board’s id.'), limit: z.number().int().min(1).max(100).optional() }),
70
+ annotations: { readOnlyHint: true, openWorldHint: false },
71
+ call: ({ id, limit }) => ({ path: `${path}/${encodeURIComponent(id)}/suggestions`, query: limit ? { limit } : {} }),
72
+ summarize: (json: { _embedded: { items: unknown[] } }) => json._embedded.items.map((item) => `- ${label(item)}`).join('\n') || 'Nothing yet: add a few items first.',
73
+ }),
74
+ )
75
+ return tools
76
+ }
@@ -0,0 +1,31 @@
1
+ # browser
2
+
3
+ The browser side, for the app's frontend (not the API): typed clients for the search and board routes, headless
4
+ React hooks over them, and an optional shadcn search block. Logic only; each app draws its own UI.
5
+
6
+ | Part | Files | What |
7
+ |---|---|---|
8
+ | `search-client` | `search-client.ts` | `createSearchClient`, `createSearchController` (debounce, cancel, picture paste and drop) |
9
+ | `search-react` | `search-react.ts` | `useVectorSearch`, `usePictureInput`, `useSimilar` |
10
+ | `vector-search-ui` | `vector-search-block.tsx` | an optional search block (shadcn `button` + `input`) |
11
+ | `boards-client` | `boards-client.ts` + `../boards/refs.ts` | `createBoardsClient`: list, create, save, unsave, suggestions; `BoardsError` carries the problem |
12
+ | `boards-react` | `boards-react.ts` | `useBoards`, `useItemBoards` |
13
+
14
+ ```bash
15
+ # into the frontend, tracked by the API's kit.json
16
+ npx @escalay/kit add boards-react --dir apps/api/src/kit --web-dir ../../web/src/kit
17
+ # or one part with the shadcn CLI, into src/kit
18
+ npx shadcn add escalay/api-kit/boards-react
19
+ ```
20
+
21
+ ```ts
22
+ const boards = createBoardsClient<RecipeCard>()
23
+ const saved = (await boards.list()).find((b) => b.name === 'Saved') ?? (await boards.create('Saved'))
24
+ await boards.save(saved.id, boardRef('recipe', slug), 'the sun')
25
+ ```
26
+
27
+ ## Rules
28
+
29
+ - The clients use the page's own sign-in (same-origin cookies); a failure is a typed error with the API's `detail`.
30
+ - Typechecked with bundler resolution (`tsconfig.web.json`).
31
+ - Standard: `docs/STANDARD.md` §16.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The browser side of `boards`, without a framework: a typed client for the board routes, run with the page's own
3
+ * sign-in. A plain viewer uses it directly; React apps use `boards-react.ts`. A failed call is a `BoardsError` carrying
4
+ * the API's problem (`code`, `detail`), so an app can show what to do.
5
+ *
6
+ * @example
7
+ * const boards = createBoardsClient<RecipeCard>()
8
+ * const saved = (await boards.list()).find((board) => board.name === 'Saved') ?? (await boards.create('Saved'))
9
+ * await boards.save(saved.id, 'recipe:soup', 'the sun')
10
+ */
11
+
12
+ export { BOARD_REF, boardRef, parseBoardRef } from '../boards/refs.js'
13
+
14
+ export type BoardEntry<Item> = { ref: string; note: string | null; addedAt: string; item: Item | null }
15
+ export type Board<Item> = { id: string; name: string; description: string | null; items: number; createdAt: string; updatedAt: string; entries?: BoardEntry<Item>[] }
16
+
17
+ /** A failed board call, as the API's problem+json described it. */
18
+ export class BoardsError extends Error {
19
+ readonly status: number
20
+ readonly code: string
21
+ readonly detail: string
22
+
23
+ constructor(status: number, code: string, detail: string) {
24
+ super(detail)
25
+ this.status = status
26
+ this.code = code
27
+ this.detail = detail
28
+ }
29
+ }
30
+
31
+ type BoardJson<Item> = Omit<Board<Item>, 'entries'> & { _embedded?: { items: BoardEntry<Item>[] } }
32
+ const boardOf = <Item>({ _embedded, ...board }: BoardJson<Item>): Board<Item> => ({ ...board, ...(_embedded ? { entries: _embedded.items } : {}) })
33
+
34
+ export type BoardsClient<Item> = {
35
+ /** Every board, newest change first; with `ref`, only the boards holding it. */
36
+ list: (ref?: string) => Promise<Board<Item>[]>
37
+ create: (name: string, description?: string) => Promise<Board<Item>>
38
+ /** A board with its entries. */
39
+ get: (id: string) => Promise<Board<Item>>
40
+ rename: (id: string, change: { name?: string; description?: string }) => Promise<Board<Item>>
41
+ remove: (id: string) => Promise<void>
42
+ /** Puts an item on a board (or changes its note); harmless to repeat. */
43
+ save: (id: string, ref: string, note?: string) => Promise<Board<Item>>
44
+ unsave: (id: string, ref: string) => Promise<void>
45
+ suggestions: (id: string, limit?: number) => Promise<Item[]>
46
+ }
47
+
48
+ /**
49
+ * A client for the routes `registerBoards` serves. `fetchImpl` and `headers` are for tests and scripts; a page needs
50
+ * neither.
51
+ *
52
+ * @example
53
+ * const boards = createBoardsClient<RecipeCard>({ path: '/api/boards' })
54
+ * const holding = await boards.list('recipe:soup') // where it is saved
55
+ */
56
+ export const createBoardsClient = <Item>({ path = '/api/boards', fetchImpl = (...args) => fetch(...args), headers = {} }: { path?: string; fetchImpl?: typeof fetch; headers?: Record<string, string> } = {}): BoardsClient<Item> => {
57
+ const call = async (url: string, init: RequestInit = {}) => {
58
+ const response = await fetchImpl(url, { credentials: 'same-origin', ...init, headers: { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...headers } })
59
+ const body = response.status === 204 ? null : await response.json().catch(() => null)
60
+ if (!response.ok) throw new BoardsError(response.status, body?.code ?? 'error', body?.detail ?? body?.title ?? `The board call failed (${response.status}).`)
61
+ return body
62
+ }
63
+ const at = (id: string) => `${path}/${encodeURIComponent(id)}`
64
+ return {
65
+ list: async (ref) => ((await call(ref ? `${path}?item=${encodeURIComponent(ref)}` : path))._embedded.boards as BoardJson<Item>[]).map(boardOf),
66
+ create: async (name, description) => boardOf(await call(path, { method: 'POST', body: JSON.stringify({ name, ...(description ? { description } : {}) }) })),
67
+ get: async (id) => boardOf(await call(at(id))),
68
+ rename: async (id, change) => boardOf(await call(at(id), { method: 'PATCH', body: JSON.stringify(change) })),
69
+ remove: async (id) => void (await call(at(id), { method: 'DELETE' })),
70
+ save: async (id, ref, note) => boardOf(await call(`${at(id)}/items/${encodeURIComponent(ref)}`, { method: 'PUT', body: JSON.stringify(note ? { note } : {}) })),
71
+ unsave: async (id, ref) => void (await call(`${at(id)}/items/${encodeURIComponent(ref)}`, { method: 'DELETE' })),
72
+ suggestions: async (id, limit) => (await call(`${at(id)}/suggestions${limit ? `?limit=${limit}` : ''}`))._embedded.items as Item[],
73
+ }
74
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * React hooks over `boards-client.ts`: the owner's boards and where one item is saved, with no markup. Each app draws
3
+ * its own lists, menus and save buttons.
4
+ *
5
+ * useBoards every board, and making, renaming and deleting them
6
+ * useItemBoards the boards holding one item, and saving it to or taking it off a board
7
+ *
8
+ * @example
9
+ * const boards = useMemo(() => createBoardsClient<RecipeCard>(), [])
10
+ * const { boards: all, create } = useBoards(boards)
11
+ * const { holding, toggle } = useItemBoards(boards, `recipe:${recipe.slug}`)
12
+ * all.map((board) => <label key={board.id}><input type="checkbox" checked={holding.has(board.id)} onChange={() => toggle(board.id)} />{board.name}</label>)
13
+ */
14
+ import { useCallback, useEffect, useState } from 'react'
15
+ import type { Board, BoardsClient, BoardsError } from './boards-client'
16
+
17
+ type Status = 'loading' | 'ready' | 'error'
18
+
19
+ /**
20
+ * Every board, newest change first, reloaded after each change made through it.
21
+ *
22
+ * @example
23
+ * const { boards, status, create, rename, remove } = useBoards(client)
24
+ */
25
+ export const useBoards = <Item>(client: BoardsClient<Item>) => {
26
+ const [boards, setBoards] = useState<Board<Item>[]>([])
27
+ const [status, setStatus] = useState<Status>('loading')
28
+ const [error, setError] = useState<BoardsError | null>(null)
29
+ const reload = useCallback(async () => {
30
+ try {
31
+ setBoards(await client.list())
32
+ setStatus('ready')
33
+ setError(null)
34
+ } catch (caught) {
35
+ setStatus('error')
36
+ setError(caught as BoardsError)
37
+ }
38
+ }, [client])
39
+ useEffect(() => void reload(), [reload])
40
+ const after =
41
+ <Args extends unknown[], Result>(change: (...args: Args) => Promise<Result>) =>
42
+ async (...args: Args) => {
43
+ const result = await change(...args)
44
+ await reload()
45
+ return result
46
+ }
47
+ return { boards, status, error, reload, create: after(client.create), rename: after(client.rename), remove: after(client.remove) }
48
+ }
49
+
50
+ /**
51
+ * Where one item is saved: the ids of the boards holding it, and saving it to or taking it off one. Stays idle without
52
+ * a ref.
53
+ *
54
+ * @example
55
+ * const { holding, toggle, save } = useItemBoards(client, `recipe:${slug}`)
56
+ */
57
+ export const useItemBoards = <Item>(client: BoardsClient<Item>, ref: string | null | undefined) => {
58
+ const [holding, setHolding] = useState<Set<string>>(new Set())
59
+ const reload = useCallback(async () => setHolding(ref ? new Set((await client.list(ref)).map((board) => board.id)) : new Set()), [client, ref])
60
+ useEffect(() => void reload().catch(() => setHolding(new Set())), [reload])
61
+ const save = async (boardId: string, note?: string) => {
62
+ if (!ref) return
63
+ await client.save(boardId, ref, note)
64
+ await reload()
65
+ }
66
+ const toggle = async (boardId: string) => {
67
+ if (!ref) return
68
+ if (holding.has(boardId)) await client.unsave(boardId, ref)
69
+ else await client.save(boardId, ref)
70
+ await reload()
71
+ }
72
+ return { holding, save, toggle, reload }
73
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The browser side of `vector-search`, without a framework: a typed client for its three routes, a controller that
3
+ * holds one search's state (words debounced, a picture at once, every new search cancelling the last), and the
4
+ * helpers that turn a paste or a drop into a picture. A plain viewer uses these directly; React apps use
5
+ * `search-react.ts`, which wraps the same controller. Nothing here draws anything: each app renders its own results.
6
+ *
7
+ * Requests run with the page's own sign-in (same-origin cookies), so the browser never holds an API key. A failed
8
+ * call is a `SearchError` carrying the API's problem: `code`, `detail`, and `retryAfter` for a 429 or 503.
9
+ *
10
+ * @example
11
+ * const client = createSearchClient<RecipeCard>({ path: '/api/search', similarPath: '/api/recipes/{id}/similar' })
12
+ * const search = createSearchController(client)
13
+ * search.subscribe(() => render(search.getState()))
14
+ * input.addEventListener('input', () => search.setWords(input.value))
15
+ * listenForPictures(window, (picture) => search.setPicture(picture))
16
+ */
17
+
18
+ export type SearchBy = 'look' | 'meaning' | 'both'
19
+ export type SearchResult<Item> = { score: number; z: number | null; space: 'look' | 'meaning'; item: Item }
20
+ /** `closest`: only when nothing cleared the bar, the nearest few below it (not matches; show them as such). */
21
+ export type SearchAnswer<Item> = { query: string | null; by: SearchBy; results: SearchResult<Item>[]; closest: SearchResult<Item>[] }
22
+ export type SearchFilters = Record<string, string | number | boolean | undefined>
23
+ export type SearchOptions = { by?: SearchBy; limit?: number; filters?: SearchFilters; signal?: AbortSignal }
24
+
25
+ /** A failed search, as the API's problem+json described it. */
26
+ export class SearchError extends Error {
27
+ readonly status: number
28
+ readonly code: string
29
+ readonly detail: string
30
+ readonly retryAfter: number | null
31
+
32
+ constructor(status: number, code: string, detail: string, retryAfter: number | null = null) {
33
+ super(detail)
34
+ this.status = status
35
+ this.code = code
36
+ this.detail = detail
37
+ this.retryAfter = retryAfter
38
+ }
39
+ }
40
+
41
+ export type SearchClient<Item> = {
42
+ /** Items that match words. */
43
+ byWords: (text: string, options?: SearchOptions) => Promise<SearchAnswer<Item>>
44
+ /** Items that look like a picture: a file or blob, or a data URL. */
45
+ byPicture: (picture: Blob | string, options?: SearchOptions) => Promise<SearchAnswer<Item>>
46
+ /** Items like one, by its id. */
47
+ similar: (id: string, options?: SearchOptions) => Promise<SearchAnswer<Item>>
48
+ }
49
+
50
+ const queryOf = ({ by, limit, filters = {} }: SearchOptions) => {
51
+ const params = new URLSearchParams()
52
+ if (by) params.set('by', by)
53
+ if (limit) params.set('limit', String(limit))
54
+ for (const [name, value] of Object.entries(filters)) if (value !== undefined && value !== '') params.set(name, String(value))
55
+ return params
56
+ }
57
+
58
+ /**
59
+ * A client for the routes `registerVectorSearch` serves. `fetchImpl` and `headers` are for tests and for callers
60
+ * outside the page (a key in a script); a page needs neither.
61
+ *
62
+ * @example
63
+ * const client = createSearchClient<RecipeCard>({ path: '/api/search', similarPath: '/api/recipes/{slug}/similar' })
64
+ * const { results } = await client.byWords('a fox made of triangles', { by: 'look', filters: { decade: '1970s' } })
65
+ */
66
+ export const createSearchClient = <Item>({ path, similarPath, fetchImpl = (...args) => fetch(...args), headers = {} }: { path: string; similarPath?: string; fetchImpl?: typeof fetch; headers?: Record<string, string> }): SearchClient<Item> => {
67
+ const call = async (url: string, init: RequestInit = {}): Promise<SearchAnswer<Item>> => {
68
+ const response = await fetchImpl(url, { credentials: 'same-origin', ...init, headers: { accept: 'application/json', ...headers, ...(init.headers as Record<string, string>) } })
69
+ const body = (await response.json().catch(() => null)) as { query?: string | null; by?: SearchBy; _embedded?: { results?: SearchResult<Item>[]; closest?: SearchResult<Item>[] }; code?: string; detail?: string; title?: string } | null
70
+ if (!response.ok) {
71
+ const retry = Number(response.headers.get('retry-after'))
72
+ throw new SearchError(response.status, body?.code ?? 'error', body?.detail ?? body?.title ?? `The search failed (${response.status}).`, Number.isFinite(retry) && retry > 0 ? retry : null)
73
+ }
74
+ return { query: body?.query ?? null, by: body?.by ?? 'both', results: body?._embedded?.results ?? [], closest: body?._embedded?.closest ?? [] }
75
+ }
76
+ return {
77
+ byWords: (text, options = {}) => {
78
+ const params = queryOf(options)
79
+ params.set('q', text)
80
+ return call(`${path}?${params}`, { signal: options.signal })
81
+ },
82
+ byPicture: (picture, options = {}) => {
83
+ const params = queryOf(options)
84
+ const suffix = params.size ? `?${params}` : ''
85
+ if (typeof picture === 'string') return call(`${path}${suffix}`, { method: 'POST', body: JSON.stringify({ image: picture }), headers: { 'content-type': 'application/json' }, signal: options.signal })
86
+ const form = new FormData()
87
+ form.append('image', picture)
88
+ return call(`${path}${suffix}`, { method: 'POST', body: form, signal: options.signal })
89
+ },
90
+ similar: (id, options = {}) => {
91
+ if (!similarPath) return Promise.reject(new SearchError(404, 'not_found', 'This client has no similarPath.'))
92
+ const params = queryOf(options)
93
+ return call(`${similarPath.replace(/\{\w+\}/, encodeURIComponent(id))}${params.size ? `?${params}` : ''}`, { signal: options.signal })
94
+ },
95
+ }
96
+ }
97
+
98
+ /** What is being searched for: nothing yet, words, a picture (with an object URL to show it), or one item's likeness. */
99
+ export type SearchInput = { kind: 'idle' } | { kind: 'words'; text: string } | { kind: 'picture'; picture: Blob; preview: string } | { kind: 'similar'; id: string }
100
+
101
+ export type SearchState<Item> = {
102
+ input: SearchInput
103
+ by: SearchBy
104
+ filters: SearchFilters
105
+ status: 'idle' | 'loading' | 'done' | 'error'
106
+ results: SearchResult<Item>[]
107
+ /** The nearest below the bar when `results` is empty. */
108
+ closest: SearchResult<Item>[]
109
+ error: SearchError | null
110
+ }
111
+
112
+ export type SearchController<Item> = {
113
+ getState: () => SearchState<Item>
114
+ /** Calls `listener` after every change; returns the unsubscribe. */
115
+ subscribe: (listener: () => void) => () => void
116
+ /** Words, debounced; fewer than `minLength` characters clears the results. */
117
+ setWords: (text: string) => void
118
+ setPicture: (picture: Blob) => void
119
+ similarTo: (id: string) => void
120
+ /** Reruns the current search with the new setting. */
121
+ setBy: (by: SearchBy) => void
122
+ setFilters: (filters: SearchFilters) => void
123
+ retry: () => void
124
+ clear: () => void
125
+ /** Cancels what is running and frees the picture preview. */
126
+ dispose: () => void
127
+ }
128
+
129
+ /**
130
+ * One search's state over a client: words wait `debounceMs` after the last keystroke, a picture or a likeness runs at
131
+ * once, and a newer search aborts an older one, so results never arrive out of order. `by` defaults to `both` for
132
+ * words and `look` for pictures and likeness, until set.
133
+ *
134
+ * @example
135
+ * const search = createSearchController(client, { limit: 48 })
136
+ * search.subscribe(() => paint(search.getState()))
137
+ * search.setWords('bauhaus lettermark')
138
+ */
139
+ export const createSearchController = <Item>(client: SearchClient<Item>, { debounceMs = 250, minLength = 2, limit, by: fixedBy, filters: initialFilters = {} }: { debounceMs?: number; minLength?: number; limit?: number; by?: SearchBy; filters?: SearchFilters } = {}): SearchController<Item> => {
140
+ let state: SearchState<Item> = { input: { kind: 'idle' }, by: fixedBy ?? 'both', filters: initialFilters, status: 'idle', results: [], closest: [], error: null }
141
+ let chosenBy = fixedBy
142
+ const listeners = new Set<() => void>()
143
+ let timer: ReturnType<typeof setTimeout> | undefined
144
+ let running: AbortController | undefined
145
+
146
+ const set = (next: Partial<SearchState<Item>>) => {
147
+ state = { ...state, ...next }
148
+ listeners.forEach((listener) => listener())
149
+ }
150
+ const freePreview = (input: SearchInput) => {
151
+ if (input.kind === 'picture') URL.revokeObjectURL(input.preview)
152
+ }
153
+ const byFor = (input: SearchInput): SearchBy => chosenBy ?? (input.kind === 'words' ? 'both' : 'look')
154
+
155
+ const run = () => {
156
+ clearTimeout(timer)
157
+ running?.abort()
158
+ const { input } = state
159
+ if (input.kind === 'idle') return set({ status: 'idle', results: [], closest: [], error: null })
160
+ const controller = new AbortController()
161
+ running = controller
162
+ const by = byFor(input)
163
+ const options = { by: input.kind === 'similar' && by === 'both' ? 'look' : by, limit, filters: state.filters, signal: controller.signal } as const
164
+ set({ status: 'loading', by, error: null })
165
+ const answer = input.kind === 'words' ? client.byWords(input.text, options) : input.kind === 'picture' ? client.byPicture(input.picture, options) : client.similar(input.id, options)
166
+ answer.then(
167
+ ({ results, closest }) => controller === running && set({ status: 'done', results, closest }),
168
+ (error: unknown) => {
169
+ if (controller !== running || controller.signal.aborted) return
170
+ set({ status: 'error', error: error instanceof SearchError ? error : new SearchError(0, 'network', 'The search could not reach the server. Check the connection and retry.') })
171
+ },
172
+ )
173
+ }
174
+ const start = (input: SearchInput, wait = 0) => {
175
+ freePreview(state.input)
176
+ clearTimeout(timer)
177
+ set({ input })
178
+ if (wait) timer = setTimeout(run, wait)
179
+ else run()
180
+ }
181
+
182
+ return {
183
+ getState: () => state,
184
+ subscribe: (listener) => {
185
+ listeners.add(listener)
186
+ return () => listeners.delete(listener)
187
+ },
188
+ setWords: (text) => (text.trim().length < minLength ? start({ kind: 'idle' }) : start({ kind: 'words', text: text.trim() }, debounceMs)),
189
+ setPicture: (picture) => start({ kind: 'picture', picture, preview: URL.createObjectURL(picture) }),
190
+ similarTo: (id) => start({ kind: 'similar', id }),
191
+ setBy: (by) => {
192
+ chosenBy = by
193
+ set({ by })
194
+ run()
195
+ },
196
+ setFilters: (filters) => {
197
+ set({ filters })
198
+ run()
199
+ },
200
+ retry: run,
201
+ clear: () => start({ kind: 'idle' }),
202
+ dispose: () => {
203
+ clearTimeout(timer)
204
+ running?.abort()
205
+ freePreview(state.input)
206
+ },
207
+ }
208
+ }
209
+
210
+ const IMAGE = /^image\/(png|jpeg|webp|gif)$/
211
+
212
+ /**
213
+ * The first picture in a paste, a drop or a file list, or null.
214
+ *
215
+ * @example
216
+ * window.addEventListener('paste', (event) => { const picture = pictureFrom(event.clipboardData) })
217
+ */
218
+ export const pictureFrom = (source: DataTransfer | FileList | null | undefined): File | null => {
219
+ if (!source) return null
220
+ const files = 'items' in source ? Array.from(source.items, (item) => (item.kind === 'file' ? item.getAsFile() : null)) : Array.from(source)
221
+ return files.find((file): file is File => !!file && IMAGE.test(file.type)) ?? null
222
+ }
223
+
224
+ /**
225
+ * Listens on `target` (the window, or a drop zone) for a pasted (⌘V) or dropped picture. A paste without a picture,
226
+ * or one another handler already took, is left alone, so typing and pasting text keep working. Returns the cleanup.
227
+ *
228
+ * @example
229
+ * const stop = listenForPictures(window, (picture) => search.setPicture(picture))
230
+ */
231
+ export const listenForPictures = (target: EventTarget, onPicture: (picture: File) => void, { paste = true, drop = true }: { paste?: boolean; drop?: boolean } = {}) => {
232
+ const onPaste = (event: Event) => {
233
+ const clipboard = (event as ClipboardEvent).clipboardData
234
+ const picture = event.defaultPrevented ? null : pictureFrom(clipboard)
235
+ if (!picture) return
236
+ event.preventDefault()
237
+ onPicture(picture)
238
+ }
239
+ const onDragOver = (event: Event) => {
240
+ if ((event as DragEvent).dataTransfer?.types.includes('Files')) event.preventDefault()
241
+ }
242
+ const onDrop = (event: Event) => {
243
+ const picture = pictureFrom((event as DragEvent).dataTransfer?.files)
244
+ if (!picture) return
245
+ event.preventDefault()
246
+ onPicture(picture)
247
+ }
248
+ if (paste) target.addEventListener('paste', onPaste)
249
+ if (drop) {
250
+ target.addEventListener('dragover', onDragOver)
251
+ target.addEventListener('drop', onDrop)
252
+ }
253
+ return () => {
254
+ target.removeEventListener('paste', onPaste)
255
+ target.removeEventListener('dragover', onDragOver)
256
+ target.removeEventListener('drop', onDrop)
257
+ }
258
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * React hooks over `search-client.ts`: the state and actions of a search, with no markup. Each app draws its own search
3
+ * box, picture chip and results; `vector-search.tsx` is one suggested way to, which an app can copy, restyle or skip.
4
+ *
5
+ * useVectorSearch one search (words, a picture, or one item's likeness) and what to do with it
6
+ * usePictureInput paste (⌘V), drop and a file picker that hand over a picture
7
+ * useSimilar items like one, reloaded when the id changes
8
+ *
9
+ * @example
10
+ * const client = useMemo(() => createSearchClient<RecipeCard>({ path: '/api/search', similarPath: '/api/recipes/{slug}/similar' }), [])
11
+ * const search = useVectorSearch(client, { limit: 48 })
12
+ * const picture = usePictureInput({ onPicture: search.setPicture })
13
+ * <input value={text} onChange={(event) => { setText(event.target.value); search.setWords(event.target.value) }} />
14
+ * <div {...picture.dropProps}>{search.results.map((result) => <Card key={result.item.id} {...result.item} />)}</div>
15
+ */
16
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ChangeEvent, type DragEvent } from 'react'
17
+ import { createSearchController, listenForPictures, pictureFrom, type SearchBy, type SearchClient, type SearchController, type SearchFilters, type SearchState } from './search-client'
18
+
19
+ type ControllerOptions = Parameters<typeof createSearchController>[1]
20
+
21
+ /**
22
+ * One search's state and actions. The controller lives as long as the component; a new client starts a new one.
23
+ *
24
+ * @example
25
+ * const { status, results, error, input, setWords, setPicture, setBy, clear } = useVectorSearch(client)
26
+ */
27
+ export const useVectorSearch = <Item>(client: SearchClient<Item>, options: ControllerOptions = {}): SearchState<Item> & Omit<SearchController<Item>, 'getState' | 'subscribe' | 'dispose'> => {
28
+ const [controller, setController] = useState(() => createSearchController(client, options))
29
+ const made = useRef(client)
30
+ useEffect(() => {
31
+ if (made.current === client) return
32
+ made.current = client
33
+ setController(createSearchController(client, options))
34
+ // Options are read once per client, like a store's initial state
35
+ // eslint-disable-next-line react-hooks/exhaustive-deps
36
+ }, [client])
37
+ useEffect(() => () => controller.dispose(), [controller])
38
+ const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState)
39
+ const { setWords, setPicture, similarTo, setBy, setFilters, retry, clear } = controller
40
+ return { ...state, setWords, setPicture, similarTo, setBy, setFilters, retry, clear }
41
+ }
42
+
43
+ const ACCEPT = 'image/png,image/jpeg,image/webp,image/gif'
44
+
45
+ /**
46
+ * Paste, drop and pick a picture. Pastes are heard on the whole window by default (`paste: 'window'`), or only while
47
+ * focus is inside `scope`; `dropProps` go on whatever should take a drop, `inputProps` on a hidden file input.
48
+ *
49
+ * @example
50
+ * const picture = usePictureInput({ onPicture: search.setPicture })
51
+ * <div {...picture.dropProps} data-dragging={picture.dragging}>…<button onClick={picture.openPicker}>Choose a picture</button><input {...picture.inputProps} /></div>
52
+ */
53
+ export const usePictureInput = ({ onPicture, paste = 'window', scope }: { onPicture: (picture: File) => void; paste?: 'window' | 'scope' | false; scope?: { current: HTMLElement | null } }) => {
54
+ const [dragging, setDragging] = useState(false)
55
+ const input = useRef<HTMLInputElement>(null)
56
+ const latest = useRef(onPicture)
57
+ latest.current = onPicture
58
+ useEffect(() => {
59
+ const target = paste === 'window' ? window : paste === 'scope' ? scope?.current : null
60
+ return target ? listenForPictures(target, (picture) => latest.current(picture), { drop: false }) : undefined
61
+ }, [paste, scope])
62
+ const take = useCallback((picture: File | null) => picture && latest.current(picture), [])
63
+ return {
64
+ dragging,
65
+ openPicker: () => input.current?.click(),
66
+ dropProps: {
67
+ onDragOver: (event: DragEvent) => {
68
+ if (!event.dataTransfer.types.includes('Files')) return
69
+ event.preventDefault()
70
+ setDragging(true)
71
+ },
72
+ onDragLeave: () => setDragging(false),
73
+ onDrop: (event: DragEvent) => {
74
+ setDragging(false)
75
+ const picture = pictureFrom(event.dataTransfer.files)
76
+ if (!picture) return
77
+ event.preventDefault()
78
+ take(picture)
79
+ },
80
+ },
81
+ inputProps: {
82
+ ref: input,
83
+ type: 'file' as const,
84
+ accept: ACCEPT,
85
+ hidden: true,
86
+ onChange: (event: ChangeEvent<HTMLInputElement>) => {
87
+ take(pictureFrom(event.target.files))
88
+ event.target.value = ''
89
+ },
90
+ },
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Items like one, by look (default) or meaning; reloads when the id, `by` or the filters change, and stays idle
96
+ * without an id.
97
+ *
98
+ * @example
99
+ * const { status, results } = useSimilar(client, recipe.slug, { by: 'look', limit: 12 })
100
+ */
101
+ export const useSimilar = <Item>(client: SearchClient<Item>, id: string | null | undefined, { by = 'look', limit, filters }: { by?: Exclude<SearchBy, 'both'>; limit?: number; filters?: SearchFilters } = {}) => {
102
+ const search = useVectorSearch(client, { by, limit, filters })
103
+ const filterKey = JSON.stringify(filters ?? {})
104
+ useEffect(() => {
105
+ if (!id) return search.clear()
106
+ search.similarTo(id)
107
+ // The controller's actions are stable; the inputs are the id and settings
108
+ // eslint-disable-next-line react-hooks/exhaustive-deps
109
+ }, [id])
110
+ useEffect(() => {
111
+ if (search.by !== by) search.setBy(by)
112
+ if (JSON.stringify(search.filters) !== filterKey) search.setFilters(filters ?? {})
113
+ // eslint-disable-next-line react-hooks/exhaustive-deps
114
+ }, [by, filterKey])
115
+ return { status: search.status, results: search.results, error: search.error, retry: search.retry }
116
+ }