@stapel/search-react 0.1.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 (195) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +203 -0
  3. package/README.md +175 -0
  4. package/dist/api/generated/schema.d.ts +375 -0
  5. package/dist/api/generated/schema.d.ts.map +1 -0
  6. package/dist/api/generated/schema.js +2 -0
  7. package/dist/api/generated/schema.js.map +1 -0
  8. package/dist/api/searchApi.d.ts +77 -0
  9. package/dist/api/searchApi.d.ts.map +1 -0
  10. package/dist/api/searchApi.js +79 -0
  11. package/dist/api/searchApi.js.map +1 -0
  12. package/dist/api/types.d.ts +131 -0
  13. package/dist/api/types.d.ts.map +1 -0
  14. package/dist/api/types.js +20 -0
  15. package/dist/api/types.js.map +1 -0
  16. package/dist/default/DegradationNotice.d.ts +19 -0
  17. package/dist/default/DegradationNotice.d.ts.map +1 -0
  18. package/dist/default/DegradationNotice.js +14 -0
  19. package/dist/default/DegradationNotice.js.map +1 -0
  20. package/dist/default/ErrorAlert.d.ts +9 -0
  21. package/dist/default/ErrorAlert.d.ts.map +1 -0
  22. package/dist/default/ErrorAlert.js +27 -0
  23. package/dist/default/ErrorAlert.js.map +1 -0
  24. package/dist/default/FacetPanelPane.d.ts +26 -0
  25. package/dist/default/FacetPanelPane.d.ts.map +1 -0
  26. package/dist/default/FacetPanelPane.js +32 -0
  27. package/dist/default/FacetPanelPane.js.map +1 -0
  28. package/dist/default/RankingDisclosurePane.d.ts +17 -0
  29. package/dist/default/RankingDisclosurePane.d.ts.map +1 -0
  30. package/dist/default/RankingDisclosurePane.js +23 -0
  31. package/dist/default/RankingDisclosurePane.js.map +1 -0
  32. package/dist/default/SearchPage.d.ts +34 -0
  33. package/dist/default/SearchPage.d.ts.map +1 -0
  34. package/dist/default/SearchPage.js +16 -0
  35. package/dist/default/SearchPage.js.map +1 -0
  36. package/dist/default/SearchResultCard.d.ts +34 -0
  37. package/dist/default/SearchResultCard.d.ts.map +1 -0
  38. package/dist/default/SearchResultCard.js +38 -0
  39. package/dist/default/SearchResultCard.js.map +1 -0
  40. package/dist/default/SearchResultsPane.d.ts +32 -0
  41. package/dist/default/SearchResultsPane.d.ts.map +1 -0
  42. package/dist/default/SearchResultsPane.js +46 -0
  43. package/dist/default/SearchResultsPane.js.map +1 -0
  44. package/dist/default/SortSelect.d.ts +22 -0
  45. package/dist/default/SortSelect.d.ts.map +1 -0
  46. package/dist/default/SortSelect.js +39 -0
  47. package/dist/default/SortSelect.js.map +1 -0
  48. package/dist/default/UrlIssueNotice.d.ts +12 -0
  49. package/dist/default/UrlIssueNotice.d.ts.map +1 -0
  50. package/dist/default/UrlIssueNotice.js +13 -0
  51. package/dist/default/UrlIssueNotice.js.map +1 -0
  52. package/dist/default/index.d.ts +40 -0
  53. package/dist/default/index.d.ts.map +1 -0
  54. package/dist/default/index.js +34 -0
  55. package/dist/default/index.js.map +1 -0
  56. package/dist/default/theme.d.ts +24 -0
  57. package/dist/default/theme.d.ts.map +1 -0
  58. package/dist/default/theme.js +39 -0
  59. package/dist/default/theme.js.map +1 -0
  60. package/dist/default/types.d.ts +12 -0
  61. package/dist/default/types.d.ts.map +1 -0
  62. package/dist/default/types.js +2 -0
  63. package/dist/default/types.js.map +1 -0
  64. package/dist/headless/FacetPanel.d.ts +61 -0
  65. package/dist/headless/FacetPanel.d.ts.map +1 -0
  66. package/dist/headless/FacetPanel.js +57 -0
  67. package/dist/headless/FacetPanel.js.map +1 -0
  68. package/dist/headless/RankingDisclosure.d.ts +39 -0
  69. package/dist/headless/RankingDisclosure.d.ts.map +1 -0
  70. package/dist/headless/RankingDisclosure.js +28 -0
  71. package/dist/headless/RankingDisclosure.js.map +1 -0
  72. package/dist/headless/SearchProvider.d.ts +19 -0
  73. package/dist/headless/SearchProvider.d.ts.map +1 -0
  74. package/dist/headless/SearchProvider.js +15 -0
  75. package/dist/headless/SearchProvider.js.map +1 -0
  76. package/dist/headless/SearchResults.d.ts +68 -0
  77. package/dist/headless/SearchResults.d.ts.map +1 -0
  78. package/dist/headless/SearchResults.js +70 -0
  79. package/dist/headless/SearchResults.js.map +1 -0
  80. package/dist/headless/SearchStateProvider.d.ts +69 -0
  81. package/dist/headless/SearchStateProvider.d.ts.map +1 -0
  82. package/dist/headless/SearchStateProvider.js +71 -0
  83. package/dist/headless/SearchStateProvider.js.map +1 -0
  84. package/dist/i18n/errorsMap.d.ts +27 -0
  85. package/dist/i18n/errorsMap.d.ts.map +1 -0
  86. package/dist/i18n/errorsMap.js +37 -0
  87. package/dist/i18n/errorsMap.js.map +1 -0
  88. package/dist/i18n/es.d.ts +13 -0
  89. package/dist/i18n/es.d.ts.map +1 -0
  90. package/dist/i18n/es.js +85 -0
  91. package/dist/i18n/es.js.map +1 -0
  92. package/dist/i18n/generated/errors.es.gen.d.ts +9 -0
  93. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  94. package/dist/i18n/generated/errors.es.gen.js +63 -0
  95. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  96. package/dist/i18n/generated/errors.gen.d.ts +359 -0
  97. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  98. package/dist/i18n/generated/errors.gen.js +183 -0
  99. package/dist/i18n/generated/errors.gen.js.map +1 -0
  100. package/dist/i18n/generated/errors.ru.gen.d.ts +9 -0
  101. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  102. package/dist/i18n/generated/errors.ru.gen.js +63 -0
  103. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  104. package/dist/i18n/keys.d.ts +107 -0
  105. package/dist/i18n/keys.d.ts.map +1 -0
  106. package/dist/i18n/keys.js +185 -0
  107. package/dist/i18n/keys.js.map +1 -0
  108. package/dist/i18n/ru.d.ts +29 -0
  109. package/dist/i18n/ru.d.ts.map +1 -0
  110. package/dist/i18n/ru.js +101 -0
  111. package/dist/i18n/ru.js.map +1 -0
  112. package/dist/index.d.ts +72 -0
  113. package/dist/index.d.ts.map +1 -0
  114. package/dist/index.js +66 -0
  115. package/dist/index.js.map +1 -0
  116. package/dist/model/context.d.ts +11 -0
  117. package/dist/model/context.d.ts.map +1 -0
  118. package/dist/model/context.js +16 -0
  119. package/dist/model/context.js.map +1 -0
  120. package/dist/model/queries.d.ts +48 -0
  121. package/dist/model/queries.d.ts.map +1 -0
  122. package/dist/model/queries.js +65 -0
  123. package/dist/model/queries.js.map +1 -0
  124. package/dist/model/queryKeys.d.ts +25 -0
  125. package/dist/model/queryKeys.d.ts.map +1 -0
  126. package/dist/model/queryKeys.js +22 -0
  127. package/dist/model/queryKeys.js.map +1 -0
  128. package/dist/model/runtime.d.ts +25 -0
  129. package/dist/model/runtime.d.ts.map +1 -0
  130. package/dist/model/runtime.js +6 -0
  131. package/dist/model/runtime.js.map +1 -0
  132. package/dist/nav/manifest.d.ts +21 -0
  133. package/dist/nav/manifest.d.ts.map +1 -0
  134. package/dist/nav/manifest.js +34 -0
  135. package/dist/nav/manifest.js.map +1 -0
  136. package/dist/router/index.d.ts +10 -0
  137. package/dist/router/index.d.ts.map +1 -0
  138. package/dist/router/index.js +34 -0
  139. package/dist/router/index.js.map +1 -0
  140. package/dist/state/degradations.d.ts +30 -0
  141. package/dist/state/degradations.d.ts.map +1 -0
  142. package/dist/state/degradations.js +52 -0
  143. package/dist/state/degradations.js.map +1 -0
  144. package/dist/state/facets.d.ts +68 -0
  145. package/dist/state/facets.d.ts.map +1 -0
  146. package/dist/state/facets.js +171 -0
  147. package/dist/state/facets.js.map +1 -0
  148. package/dist/state/urlState.d.ts +143 -0
  149. package/dist/state/urlState.d.ts.map +1 -0
  150. package/dist/state/urlState.js +319 -0
  151. package/dist/state/urlState.js.map +1 -0
  152. package/llms.txt +87 -0
  153. package/manifest.json +652 -0
  154. package/nav-manifest.json +44 -0
  155. package/package.json +133 -0
  156. package/src/analytics/generated/events.json +7 -0
  157. package/src/api/generated/schema.ts +377 -0
  158. package/src/api/searchApi.ts +169 -0
  159. package/src/api/types.ts +160 -0
  160. package/src/default/DegradationNotice.tsx +50 -0
  161. package/src/default/ErrorAlert.tsx +46 -0
  162. package/src/default/FacetPanelPane.tsx +174 -0
  163. package/src/default/RankingDisclosurePane.tsx +116 -0
  164. package/src/default/SearchPage.tsx +87 -0
  165. package/src/default/SearchResultCard.tsx +96 -0
  166. package/src/default/SearchResultsPane.tsx +173 -0
  167. package/src/default/SortSelect.tsx +77 -0
  168. package/src/default/UrlIssueNotice.tsx +37 -0
  169. package/src/default/index.ts +48 -0
  170. package/src/default/theme.tsx +66 -0
  171. package/src/default/types.ts +12 -0
  172. package/src/headless/FacetPanel.tsx +113 -0
  173. package/src/headless/RankingDisclosure.tsx +58 -0
  174. package/src/headless/SearchProvider.tsx +20 -0
  175. package/src/headless/SearchResults.tsx +149 -0
  176. package/src/headless/SearchStateProvider.tsx +175 -0
  177. package/src/i18n/errorsMap.ts +50 -0
  178. package/src/i18n/es.ts +110 -0
  179. package/src/i18n/generated/errors.es.gen.ts +69 -0
  180. package/src/i18n/generated/errors.gen.ts +214 -0
  181. package/src/i18n/generated/errors.json +423 -0
  182. package/src/i18n/generated/errors.ru.gen.ts +69 -0
  183. package/src/i18n/keys.ts +220 -0
  184. package/src/i18n/ru.ts +126 -0
  185. package/src/index.ts +154 -0
  186. package/src/model/context.tsx +28 -0
  187. package/src/model/queries.ts +79 -0
  188. package/src/model/queryKeys.ts +48 -0
  189. package/src/model/runtime.ts +33 -0
  190. package/src/nav/manifest.ts +53 -0
  191. package/src/router/index.ts +38 -0
  192. package/src/state/degradations.ts +75 -0
  193. package/src/state/facets.ts +219 -0
  194. package/src/state/urlState.ts +458 -0
  195. package/tsconfig.json +26 -0
package/package.json ADDED
@@ -0,0 +1,133 @@
1
+ {
2
+ "name": "@stapel/search-react",
3
+ "version": "0.1.0",
4
+ "description": "Headless React pair for stapel-search: a typed query client, TanStack Query hooks, and a URL-first state codec that makes a search shareable by construction (filters, ranges, geo, sort and the keyset cursor all live in the query string). Drill-down facets rendered with their remaining counts and with the server's own honesty flags — approximate, skipped, degraded — never swallowed; keyset pagination with the window refusal named; DSA Art. 26 `promoted` marking carried into every card slot and the P2B Art. 5 ranking disclosure exposed as data. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin, and /router binds the codec to react-router's useSearchParams.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/usestapel/stapel-react.git",
9
+ "directory": "packages/search-react"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./default": {
21
+ "types": "./dist/default/index.d.ts",
22
+ "default": "./dist/default/index.js"
23
+ },
24
+ "./router": {
25
+ "types": "./dist/router/index.d.ts",
26
+ "default": "./dist/router/index.js"
27
+ },
28
+ "./i18n/ru": {
29
+ "types": "./dist/i18n/ru.d.ts",
30
+ "default": "./dist/i18n/ru.js"
31
+ },
32
+ "./i18n/es": {
33
+ "types": "./dist/i18n/es.d.ts",
34
+ "default": "./dist/i18n/es.js"
35
+ },
36
+ "./manifest": "./manifest.json",
37
+ "./manifest.json": "./manifest.json",
38
+ "./nav-manifest.json": "./nav-manifest.json",
39
+ "./llms.txt": "./llms.txt",
40
+ "./package.json": "./package.json"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "src",
45
+ "tsconfig.json",
46
+ "README.md",
47
+ "MODULE.md",
48
+ "CHANGELOG.md",
49
+ "manifest.json",
50
+ "nav-manifest.json",
51
+ "llms.txt"
52
+ ],
53
+ "scripts": {
54
+ "build": "tsc -p tsconfig.json",
55
+ "test": "tsc -p tsconfig.test.json && vitest run --exclude test/prodBundlePurity.test.ts",
56
+ "lint": "eslint .",
57
+ "size": "size-limit",
58
+ "test:pack": "vitest run test/prodBundlePurity.test.ts"
59
+ },
60
+ "size-limit": [
61
+ {
62
+ "name": "index — the headless pair (client + hooks + URL codec + facet model): no antd, no react-router",
63
+ "path": "dist/index.js",
64
+ "limit": "10 KB"
65
+ },
66
+ {
67
+ "name": "default — the antd skin (results, facet panel, degradation notice, ranking disclosure) must stay out of the main bundle",
68
+ "path": "dist/default/index.js",
69
+ "limit": "13 KB"
70
+ },
71
+ {
72
+ "name": "router — the react-router binding is opt-in; the main entry must never pull a router",
73
+ "path": "dist/router/index.js",
74
+ "limit": "1 KB"
75
+ },
76
+ {
77
+ "name": "i18n/ru — the Russian bundle is opt-in, not carried by the main entry",
78
+ "path": "dist/i18n/ru.js",
79
+ "limit": "4.5 KB"
80
+ },
81
+ {
82
+ "name": "i18n/es — the Spanish bundle is opt-in, not carried by the main entry",
83
+ "path": "dist/i18n/es.js",
84
+ "limit": "3.5 KB"
85
+ }
86
+ ],
87
+ "peerDependencies": {
88
+ "@stapel/attributes-react": ">=0.1.0",
89
+ "@stapel/core": ">=0.15.0 <1.0.0",
90
+ "@stapel/tokens-antd": ">=0.5.0",
91
+ "@tanstack/react-query": "^5.0.0",
92
+ "antd": ">=5.20.0 <7",
93
+ "react": ">=19",
94
+ "react-router": ">=7.0.0 <8"
95
+ },
96
+ "peerDependenciesMeta": {
97
+ "@stapel/tokens-antd": {
98
+ "optional": true
99
+ },
100
+ "antd": {
101
+ "optional": true
102
+ },
103
+ "react-router": {
104
+ "optional": true
105
+ }
106
+ },
107
+ "devDependencies": {
108
+ "@size-limit/preset-small-lib": "^11.2.0",
109
+ "@stapel/attributes-react": "workspace:^",
110
+ "@stapel/core": "workspace:^",
111
+ "@stapel/showcase": "workspace:^",
112
+ "@stapel/tokens": "workspace:^",
113
+ "@stapel/tokens-antd": "workspace:^",
114
+ "@tanstack/react-query": "^5.81.0",
115
+ "@testing-library/react": "^16.3.0",
116
+ "@types/react": "^19.1.0",
117
+ "@types/react-dom": "^19.1.0",
118
+ "antd": "^6.5.0",
119
+ "jsdom": "^26.1.0",
120
+ "react": "^19.1.0",
121
+ "react-dom": "^19.1.0",
122
+ "react-router": "^7.18.1",
123
+ "size-limit": "^11.2.0",
124
+ "typescript": "^5.8.3",
125
+ "vitest": "^3.2.4"
126
+ },
127
+ "engines": {
128
+ "node": ">=22"
129
+ },
130
+ "publishConfig": {
131
+ "access": "public"
132
+ }
133
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
+ "package": "@stapel/search-react",
4
+ "version": "0.1.0",
5
+ "defined": [],
6
+ "flows": []
7
+ }
@@ -0,0 +1,377 @@
1
+ // AUTO-GENERATED by scripts/gen-api.mjs — do not edit by hand.
2
+ // Source: the backend module's own docs/schema.json (§17-native per-module contract).
3
+ // Regenerate: pnpm gen:api · Drift gate: pnpm gen:api:check
4
+ export interface paths {
5
+ "/search/api/v1/health": {
6
+ parameters: {
7
+ query?: never;
8
+ header?: never;
9
+ path?: never;
10
+ cookie?: never;
11
+ };
12
+ /**
13
+ * Engine reachability, capabilities and index lag
14
+ * @description ``GET /search/api/v1/health`` — backend, capabilities, index lag.
15
+ *
16
+ * **Permissions:** `IsNotAnonymousUser`
17
+ */
18
+ get: operations["search_api_v1_health_retrieve"];
19
+ put?: never;
20
+ post?: never;
21
+ delete?: never;
22
+ options?: never;
23
+ head?: never;
24
+ patch?: never;
25
+ trace?: never;
26
+ };
27
+ "/search/api/v1/query": {
28
+ parameters: {
29
+ query?: never;
30
+ header?: never;
31
+ path?: never;
32
+ cookie?: never;
33
+ };
34
+ /**
35
+ * Search one registered document type
36
+ * @description ``GET /search/api/v1/query`` — the whole read contract.
37
+ *
38
+ * **Permissions:** `AllowAny`
39
+ */
40
+ get: operations["search_api_v1_query_retrieve"];
41
+ put?: never;
42
+ post?: never;
43
+ delete?: never;
44
+ options?: never;
45
+ head?: never;
46
+ patch?: never;
47
+ trace?: never;
48
+ };
49
+ "/search/api/v1/ranking": {
50
+ parameters: {
51
+ query?: never;
52
+ header?: never;
53
+ path?: never;
54
+ cookie?: never;
55
+ };
56
+ /**
57
+ * Ranking disclosure (P2B Art. 5), generated from the scorer registry
58
+ * @description ``GET /search/api/v1/ranking`` — the P2B Art. 5 disclosure.
59
+ *
60
+ * Generated from the scorer registry and annotated with what the
61
+ * configured engine can actually evaluate, so it cannot claim a parameter
62
+ * is in effect when the backend cannot compute it.
63
+ *
64
+ * **Permissions:** `AllowAny`
65
+ */
66
+ get: operations["search_api_v1_ranking_retrieve"];
67
+ put?: never;
68
+ post?: never;
69
+ delete?: never;
70
+ options?: never;
71
+ head?: never;
72
+ patch?: never;
73
+ trace?: never;
74
+ };
75
+ "/search/api/v1/reindex": {
76
+ parameters: {
77
+ query?: never;
78
+ header?: never;
79
+ path?: never;
80
+ cookie?: never;
81
+ };
82
+ get?: never;
83
+ put?: never;
84
+ /**
85
+ * Re-pull specific keys, or rebuild a whole document type
86
+ * @description ``POST /search/api/v1/reindex`` — targeted re-pull or a full rebuild.
87
+ *
88
+ * **Permissions:** `IsNotAnonymousUser`
89
+ */
90
+ post: operations["search_api_v1_reindex_create"];
91
+ delete?: never;
92
+ options?: never;
93
+ head?: never;
94
+ patch?: never;
95
+ trace?: never;
96
+ };
97
+ "/search/api/v1/suggest": {
98
+ parameters: {
99
+ query?: never;
100
+ header?: never;
101
+ path?: never;
102
+ cookie?: never;
103
+ };
104
+ /**
105
+ * Title prefixes from the index
106
+ * @description ``GET /search/api/v1/suggest`` — title prefixes out of the index.
107
+ *
108
+ * Not out of a query log: no query log is kept, which is a privacy
109
+ * decision before it is a product one, and on day one there would be
110
+ * nothing in it anyway.
111
+ *
112
+ * **Permissions:** `AllowAny`
113
+ */
114
+ get: operations["search_api_v1_suggest_retrieve"];
115
+ put?: never;
116
+ post?: never;
117
+ delete?: never;
118
+ options?: never;
119
+ head?: never;
120
+ patch?: never;
121
+ trace?: never;
122
+ };
123
+ }
124
+ export type webhooks = Record<string, never>;
125
+ export interface components {
126
+ schemas: {
127
+ FacetMeta: {
128
+ /** @description True when counts came from a sample because the candidate set exceeded FACET_CANDIDATE_CAP. */
129
+ approximate: boolean;
130
+ /** @description Size of the largest counted set. */
131
+ candidates: number;
132
+ counted: string[];
133
+ /** @description Plan slugs dropped at MAX_FACET_FIELDS — reported, not vanished. */
134
+ skipped: string[];
135
+ };
136
+ HealthResponse: {
137
+ backend: string;
138
+ reachable: boolean;
139
+ detail: string;
140
+ documents?: number | null;
141
+ capabilities?: {
142
+ [key: string]: unknown;
143
+ };
144
+ types: string[];
145
+ lag_seconds?: number | null;
146
+ stale_reason?: string;
147
+ };
148
+ /** @description The P2B Art. 5 disclosure, generated from the scorer registry. */
149
+ RankingResponse: {
150
+ doc_type: string;
151
+ backend: string;
152
+ scorers: components["schemas"]["Scorer"][];
153
+ notes: string[];
154
+ };
155
+ ReindexRequest: {
156
+ doc_type: string;
157
+ /** @description Re-pull exactly these keys; omit to rebuild the whole type. */
158
+ keys?: string[];
159
+ };
160
+ ReindexResponse: {
161
+ doc_type: string;
162
+ indexed: number;
163
+ removed: number;
164
+ skipped_stale: number;
165
+ skipped_duplicate: number;
166
+ };
167
+ Scorer: {
168
+ slug: string;
169
+ /** Format: double */
170
+ weight: number;
171
+ description_key: string;
172
+ description: string;
173
+ params: {
174
+ [key: string]: unknown;
175
+ };
176
+ applies_to_sorts: string[];
177
+ /** @description False when the configured engine cannot evaluate this parameter. */
178
+ active: boolean;
179
+ inactive_reason: string;
180
+ };
181
+ /** @description One result row. */
182
+ SearchItem: {
183
+ /** @description The source's own document key. */
184
+ key: string;
185
+ /**
186
+ * Format: double
187
+ * @description Ranking score; 0 under an explicit sort.
188
+ */
189
+ score: number;
190
+ /** @description Whether this result is promoted. Present on EVERY item under EVERY sort, including when false — a mandatory marking (DSA Art. 26), not an optional field. */
191
+ promoted: boolean;
192
+ /**
193
+ * Format: double
194
+ * @description Great-circle distance from the searched centre.
195
+ */
196
+ distance_km: number | null;
197
+ /** @description Stored row fields, so a result page costs one query. */
198
+ card: {
199
+ [key: string]: unknown;
200
+ };
201
+ };
202
+ /** @description The query envelope: AnchorPagination's keys, plus what search owes. */
203
+ SearchResponse: {
204
+ items: components["schemas"]["SearchItem"][];
205
+ /** @description {slug: {value: count}}, counted with the slug's own filter removed. */
206
+ facets: {
207
+ [key: string]: {
208
+ [key: string]: number;
209
+ };
210
+ };
211
+ facet_meta: components["schemas"]["FacetMeta"];
212
+ next_anchor: string | null;
213
+ prev_anchor: string | null;
214
+ has_next: boolean;
215
+ has_prev: boolean;
216
+ count: number;
217
+ exact_total: boolean;
218
+ /** @description What the configured engine could not do for this query. */
219
+ degraded: string[];
220
+ backend: string;
221
+ sort: string;
222
+ took_ms: number;
223
+ };
224
+ SuggestResponse: {
225
+ items: string[];
226
+ backend: string;
227
+ };
228
+ };
229
+ responses: never;
230
+ parameters: never;
231
+ requestBodies: never;
232
+ headers: never;
233
+ pathItems: never;
234
+ }
235
+ export type $defs = Record<string, never>;
236
+ export interface operations {
237
+ search_api_v1_health_retrieve: {
238
+ parameters: {
239
+ query?: never;
240
+ header?: never;
241
+ path?: never;
242
+ cookie?: never;
243
+ };
244
+ requestBody?: never;
245
+ responses: {
246
+ 200: {
247
+ headers: {
248
+ [name: string]: unknown;
249
+ };
250
+ content: {
251
+ "application/json": components["schemas"]["HealthResponse"];
252
+ };
253
+ };
254
+ };
255
+ };
256
+ search_api_v1_query_retrieve: {
257
+ parameters: {
258
+ query: {
259
+ /** @description Opaque keyset cursor from a previous answer. */
260
+ anchor?: string;
261
+ /** @description minLat,minLon,maxLat,maxLon. minLon > maxLon means the box crosses +/-180. */
262
+ bbox?: string;
263
+ /** @description root/leaf path; a prefix filter, so a parent finds its descendants. */
264
+ category?: string;
265
+ /** @description next | prev. */
266
+ direction?: string;
267
+ /** @description Facet filter. Repeat for OR within a slug; different slugs AND together. */
268
+ "f.<slug>"?: string;
269
+ /** @description on | off | comma-separated slugs. Default is the category's plan. */
270
+ facets?: string;
271
+ /** @description Language of the query: selects the analyzer AND narrows the corpus. Omit it and only the analyzer is chosen (from Accept-Language) — a header must not hide a catalogue. */
272
+ lang?: string;
273
+ /** @description Centre latitude (with lon). */
274
+ lat?: number;
275
+ limit?: number;
276
+ /** @description Centre longitude (with lat). */
277
+ lon?: number;
278
+ /** @description Opaque owner key — the seller's own listings. */
279
+ owner?: string;
280
+ /** @description Free text. Dictionary-normalized here; morphology belongs to the engine. */
281
+ q?: string;
282
+ /** @description Range filter, `from..to`; either end may be omitted. */
283
+ "r.<slug>"?: string;
284
+ /** @description Radius around the centre. */
285
+ radius_km?: number;
286
+ /** @description relevance | newest | price_asc | price_desc | distance. An explicit sort never receives a promotional boost. */
287
+ sort?: string;
288
+ /** @description Registered doc_type. One type per query — federated search across types is not in v1. */
289
+ type: string;
290
+ };
291
+ header?: never;
292
+ path?: never;
293
+ cookie?: never;
294
+ };
295
+ requestBody?: never;
296
+ responses: {
297
+ 200: {
298
+ headers: {
299
+ [name: string]: unknown;
300
+ };
301
+ content: {
302
+ "application/json": components["schemas"]["SearchResponse"];
303
+ };
304
+ };
305
+ };
306
+ };
307
+ search_api_v1_ranking_retrieve: {
308
+ parameters: {
309
+ query?: {
310
+ type?: string;
311
+ };
312
+ header?: never;
313
+ path?: never;
314
+ cookie?: never;
315
+ };
316
+ requestBody?: never;
317
+ responses: {
318
+ 200: {
319
+ headers: {
320
+ [name: string]: unknown;
321
+ };
322
+ content: {
323
+ "application/json": components["schemas"]["RankingResponse"];
324
+ };
325
+ };
326
+ };
327
+ };
328
+ search_api_v1_reindex_create: {
329
+ parameters: {
330
+ query?: never;
331
+ header?: never;
332
+ path?: never;
333
+ cookie?: never;
334
+ };
335
+ requestBody: {
336
+ content: {
337
+ "application/json": components["schemas"]["ReindexRequest"];
338
+ "application/x-www-form-urlencoded": components["schemas"]["ReindexRequest"];
339
+ "multipart/form-data": components["schemas"]["ReindexRequest"];
340
+ };
341
+ };
342
+ responses: {
343
+ 200: {
344
+ headers: {
345
+ [name: string]: unknown;
346
+ };
347
+ content: {
348
+ "application/json": components["schemas"]["ReindexResponse"];
349
+ };
350
+ };
351
+ };
352
+ };
353
+ search_api_v1_suggest_retrieve: {
354
+ parameters: {
355
+ query: {
356
+ limit?: number;
357
+ /** @description Title prefix. */
358
+ q?: string;
359
+ type: string;
360
+ };
361
+ header?: never;
362
+ path?: never;
363
+ cookie?: never;
364
+ };
365
+ requestBody?: never;
366
+ responses: {
367
+ 200: {
368
+ headers: {
369
+ [name: string]: unknown;
370
+ };
371
+ content: {
372
+ "application/json": components["schemas"]["SuggestResponse"];
373
+ };
374
+ };
375
+ };
376
+ };
377
+ }
@@ -0,0 +1,169 @@
1
+ import type { StapelClient } from "@stapel/core";
2
+ import type {
3
+ RankingResponse,
4
+ SearchQueryState,
5
+ SearchResponse,
6
+ SuggestParams,
7
+ SuggestResponse,
8
+ } from "./types.js";
9
+
10
+ /**
11
+ * The pair's typed operation surface — one method per stapel-search endpoint a
12
+ * storefront may call, bound to the injected {@link StapelClient} (the
13
+ * per-module override seam of frontend-standard §7.2). Paths are relative to
14
+ * the runtime's `baseUrl` (`/search/api/v1/`).
15
+ *
16
+ * ── The three PUBLIC reads, and the two that are not here ─────────────────
17
+ *
18
+ * `query`, `suggest` and `ranking` are `AllowAny` (`stapel_anonymous_access =
19
+ * ANONYMOUS_ALLOWED`): a storefront calls them with no session at all, which
20
+ * is what lets a catalogue render for a visitor who will never sign in.
21
+ *
22
+ * `GET /health` and `POST /reindex` are NOT on this interface. Both are
23
+ * `IsNotAnonymousUser` + an in-view `can_manage` check that answers
24
+ * `error.403.search_forbidden` — they are index-operator endpoints, not
25
+ * storefront ones, and a pair that exposed them would invite a public screen
26
+ * to call something only an operator may. They stay in the generated schema
27
+ * (and therefore in `manifest.json`, which lists the whole contract), so
28
+ * nothing is hidden; they are simply not this pair's surface.
29
+ *
30
+ * These operations will be GENERATED from schema.json operationIds by gen-api
31
+ * v2 (task `core-typed-ops`); until then they are hand-authored here (the ONE
32
+ * legal home of path strings — `stapel/no-string-paths` §2.3 carve-out).
33
+ */
34
+ export interface SearchApi {
35
+ readonly client: StapelClient;
36
+
37
+ /**
38
+ * One keyset page of results, with the facet counts for the current
39
+ * candidate set.
40
+ *
41
+ * Refusals a skin must tell apart, all 400 with the offending value in
42
+ * `params`: `error.400.search_unknown_doc_type`, `…_unknown_sort`,
43
+ * `…_sort_needs_center`, `…_bad_geo`, `…_bad_range`, `…_bad_cursor`,
44
+ * `…_query_too_long`, `…_too_many_facets`, `…_too_many_ranges`, and
45
+ * `…_window_exceeded` — which is "narrow the search", NOT "nothing found".
46
+ * A dead engine answers 503 `error.503.search_backend_unavailable`; the
47
+ * views turn EVERY backend exception into that, so a 500 never reaches a
48
+ * client and "we could not ask" is always distinguishable from "no hits".
49
+ */
50
+ query(
51
+ state: SearchQueryState,
52
+ options?: { readonly signal?: AbortSignal }
53
+ ): Promise<SearchResponse>;
54
+
55
+ /**
56
+ * Title-prefix suggestions. Typed and callable, but this pair ships NO hook
57
+ * and no autocomplete widget over it (spec §4.2 defers the debounce +
58
+ * keyboard work). A host that wants one today calls this directly rather
59
+ * than waiting for a version of the pair that has it.
60
+ */
61
+ suggest(
62
+ params: SuggestParams,
63
+ options?: { readonly signal?: AbortSignal }
64
+ ): Promise<SuggestResponse>;
65
+
66
+ /**
67
+ * The P2B Art. 5 ranking disclosure for a doc type: which parameters rank
68
+ * results, their weights, and — per parameter — whether the configured
69
+ * engine can actually evaluate it (`active` / `inactive_reason`). A
70
+ * disclosure that listed a parameter the engine ignores would be a
71
+ * disclosure of the wrong ranking.
72
+ */
73
+ ranking(
74
+ type?: string,
75
+ options?: { readonly signal?: AbortSignal }
76
+ ): Promise<RankingResponse>;
77
+ }
78
+
79
+ /**
80
+ * The wire query object for one search state.
81
+ *
82
+ * Exported because it is also what the query KEY is built from: the key and
83
+ * the request are then the same value by construction, and a filter that
84
+ * changes the URL but not the key (the classic stale-page bug) cannot happen.
85
+ *
86
+ * `f.<slug>` is an ARRAY — repeated keys are the OR, and `@stapel/core`'s
87
+ * client repeats an array-valued key rather than collapsing it (core 0.15.0).
88
+ */
89
+ export function searchQueryParams(
90
+ state: SearchQueryState
91
+ ): Record<
92
+ string,
93
+ string | number | boolean | undefined | readonly (string | number | boolean)[]
94
+ > {
95
+ const query: Record<
96
+ string,
97
+ | string
98
+ | number
99
+ | boolean
100
+ | undefined
101
+ | readonly (string | number | boolean)[]
102
+ > = { type: state.type };
103
+
104
+ if (state.q.length > 0) query["q"] = state.q;
105
+ if (state.lang !== undefined) query["lang"] = state.lang;
106
+ if (state.category !== undefined) query["category"] = state.category;
107
+ if (state.owner !== undefined) query["owner"] = state.owner;
108
+
109
+ for (const [slug, values] of Object.entries(state.filters)) {
110
+ if (values.length > 0) query[`f.${slug}`] = values;
111
+ }
112
+ for (const [slug, range] of Object.entries(state.ranges)) {
113
+ if (range.from === undefined && range.to === undefined) continue;
114
+ query[`r.${slug}`] = `${range.from ?? ""}..${range.to ?? ""}`;
115
+ }
116
+
117
+ if (state.geo !== undefined) {
118
+ if (state.geo.kind === "bbox") {
119
+ const { minLat, minLon, maxLat, maxLon } = state.geo;
120
+ query["bbox"] = `${minLat},${minLon},${maxLat},${maxLon}`;
121
+ } else {
122
+ query["lat"] = state.geo.lat;
123
+ query["lon"] = state.geo.lon;
124
+ if (state.geo.radiusKm !== undefined) {
125
+ query["radius_km"] = state.geo.radiusKm;
126
+ }
127
+ }
128
+ }
129
+
130
+ if (state.sort !== undefined) query["sort"] = state.sort;
131
+ if (state.facets !== undefined) {
132
+ query["facets"] = Array.isArray(state.facets)
133
+ ? state.facets.join(",")
134
+ : (state.facets as string);
135
+ }
136
+ if (state.anchor !== undefined) query["anchor"] = state.anchor;
137
+ if (state.direction !== undefined) query["direction"] = state.direction;
138
+ if (state.limit !== undefined) query["limit"] = state.limit;
139
+
140
+ return query;
141
+ }
142
+
143
+ export function createSearchApi(client: StapelClient): SearchApi {
144
+ return {
145
+ client,
146
+
147
+ query: (state, options) =>
148
+ client.get("/query", {
149
+ query: searchQueryParams(state),
150
+ ...(options?.signal !== undefined ? { signal: options.signal } : {}),
151
+ }),
152
+
153
+ suggest: (params, options) =>
154
+ client.get("/suggest", {
155
+ query: {
156
+ type: params.type,
157
+ ...(params.q !== undefined ? { q: params.q } : {}),
158
+ ...(params.limit !== undefined ? { limit: params.limit } : {}),
159
+ },
160
+ ...(options?.signal !== undefined ? { signal: options.signal } : {}),
161
+ }),
162
+
163
+ ranking: (type, options) =>
164
+ client.get("/ranking", {
165
+ query: type !== undefined ? { type } : {},
166
+ ...(options?.signal !== undefined ? { signal: options.signal } : {}),
167
+ }),
168
+ };
169
+ }