@cavuno/board 1.38.0 → 1.40.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 (45) hide show
  1. package/README.md +7 -0
  2. package/dist/{jobs-DMH3Ytoq.d.mts → _spec-DRbgPIlN.d.mts} +404 -96
  3. package/dist/{jobs-DMH3Ytoq.d.ts → _spec-DRbgPIlN.d.ts} +404 -96
  4. package/dist/bin.mjs +242 -18
  5. package/dist/{board-CBry4f7H.d.mts → board-BxPUtrOl.d.ts} +1 -1
  6. package/dist/{board-tV-zcaHs.d.ts → board-DCiNpgFf.d.mts} +1 -1
  7. package/dist/doctor.js +238 -14
  8. package/dist/doctor.mjs +238 -14
  9. package/dist/filters.d.mts +18 -4
  10. package/dist/filters.d.ts +18 -4
  11. package/dist/filters.js +17 -0
  12. package/dist/filters.mjs +17 -0
  13. package/dist/format.d.mts +3 -2
  14. package/dist/format.d.ts +3 -2
  15. package/dist/index.d.mts +62 -73
  16. package/dist/index.d.ts +62 -73
  17. package/dist/index.js +71 -1
  18. package/dist/index.mjs +71 -1
  19. package/dist/jobs-DAGAVHQL.d.ts +105 -0
  20. package/dist/jobs-DhePKSRe.d.mts +105 -0
  21. package/dist/{salaries-CaGlcCC6.d.ts → salaries-D6SUVMmt.d.mts} +2 -1
  22. package/dist/{salaries-01g4wK8y.d.mts → salaries-cqb78kg0.d.ts} +2 -1
  23. package/dist/search-BalPAS0P.d.ts +81 -0
  24. package/dist/search-DYUQzCq_.d.mts +81 -0
  25. package/dist/seo.d.mts +4 -3
  26. package/dist/seo.d.ts +4 -3
  27. package/dist/server.d.mts +56 -4
  28. package/dist/server.d.ts +56 -4
  29. package/dist/server.js +41 -0
  30. package/dist/server.mjs +41 -0
  31. package/dist/sitemap.d.mts +5 -3
  32. package/dist/sitemap.d.ts +5 -3
  33. package/dist/suggest.d.mts +70 -0
  34. package/dist/suggest.d.ts +70 -0
  35. package/dist/suggest.js +165 -0
  36. package/dist/suggest.mjs +144 -0
  37. package/dist/theme.d.mts +49 -1
  38. package/dist/theme.d.ts +49 -1
  39. package/dist/theme.js +60 -26
  40. package/dist/theme.mjs +60 -26
  41. package/package.json +11 -1
  42. package/skills/cavuno-board-filters/SKILL.md +11 -4
  43. package/skills/cavuno-board-jobs/SKILL.md +18 -2
  44. package/skills/cavuno-board-suggest/SKILL.md +119 -0
  45. package/skills/manifest.json +9 -2
package/dist/server.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { BoardSdk } from './index.js';
2
- import './jobs-DMH3Ytoq.js';
3
- import './board-tV-zcaHs.js';
4
- import './salaries-CaGlcCC6.js';
2
+ import './_spec-DRbgPIlN.js';
3
+ import './search-BalPAS0P.js';
4
+ import './board-BxPUtrOl.js';
5
+ import './jobs-DAGAVHQL.js';
6
+ import './salaries-cqb78kg0.js';
5
7
 
6
8
  /**
7
9
  * Session cookie codec — pure (no framework imports, no node imports) so it
@@ -75,6 +77,56 @@ declare function safeRedirectPath(path: string | undefined | null, defaultPath?:
75
77
  */
76
78
  declare function currentPathFromReferer(referer: string | null): string;
77
79
 
80
+ /**
81
+ * MIG-10 (added scope) — keep `<slug>.cavuno.app` live after a custom-domain
82
+ * cutover by 308-redirecting to the board's canonical custom domain.
83
+ *
84
+ * ## Where this lives (DEVIATION LOUDLY)
85
+ * **Serving-side, not the dispatch edge.** Production hostname→worker
86
+ * routing is the WFP dispatch Worker's KV map (`hostname → workerName`
87
+ * only — see `docs/recipes/ops/wfp-dispatch-custom-domain.md` and
88
+ * `createWfpReleaseDeploy` / `deterministicWorkerName`). That edge has
89
+ * **no board domain data** (no Convex, no primaryDomain). Inventing a
90
+ * second edge datastore for canonical hosts is forbidden by the ticket.
91
+ *
92
+ * Data source: the board's public context already carries
93
+ * `primaryDomain: string | null` (`GET /v1/boards/:id` via
94
+ * `serializeBoardContext`). The starter loads context for every request
95
+ * (theme, features, analytics) — reuse that field. When `primaryDomain`
96
+ * is set, a request whose Host is a `*.cavuno.app` serving host 308s to
97
+ * `https://<primaryDomain><path+query>` in **one hop**. Domainless boards
98
+ * (`primaryDomain === null`) keep **serving** from cavuno.app (no redirect).
99
+ *
100
+ * Wire this helper into the starter's root middleware / entry (the
101
+ * migration starter's host check) with `Response.redirect(url, 308)`.
102
+ * Pure so unit tests pin behaviour without a Worker runtime.
103
+ *
104
+ * Pattern: Vercel `*.vercel.app` / GitHub `github.io` permanent fallback
105
+ * origin — SEO-neutral 308 to the canonical production host.
106
+ */
107
+ /**
108
+ * True when the request host is a cavuno.app board-serving host
109
+ * (slug or board-hash subdomain), not the apex and not a preview host
110
+ * we deliberately leave alone.
111
+ */
112
+ declare function isCavunoAppServingHost(hostname: string): boolean;
113
+ /**
114
+ * When a custom-domain board is hit on its cavuno.app fallback origin,
115
+ * return the one-hop 308 Location to the canonical custom domain.
116
+ * Domainless boards (no primaryDomain) return null — serve in place.
117
+ */
118
+ declare function getCavunoAppCanonicalRedirectUrl(params: {
119
+ currentHost: string | null;
120
+ /**
121
+ * From `board.context().primaryDomain` — the board's active primary
122
+ * custom domain hostname, or null when domainless.
123
+ */
124
+ primaryDomain: string | null | undefined;
125
+ /** Full request URL or path+query; path+query is preserved on the hop. */
126
+ requestUrl?: string | null;
127
+ defaultPath?: string;
128
+ }): string | null;
129
+
78
130
  /**
79
131
  * Single-flight session refresh — dedupes concurrent refreshes for the same
80
132
  * session WITHIN one process/isolate (the rotation race, ADR-0057 wart #4,
@@ -109,4 +161,4 @@ declare function currentPathFromReferer(referer: string | null): string;
109
161
  */
110
162
  declare function createSessionRefresher(board: Pick<BoardSdk, 'auth' | 'client'>): (session: BoardSession) => Promise<BoardSession | null>;
111
163
 
112
- export { BOARD_ACCESS_COOKIE_NAME, type BoardSession, SESSION_COOKIE_NAME, clearGrantCookie, clearSessionCookie, createSessionRefresher, currentPathFromReferer, grantCookieName, isExpiringSoon, parseGrantCookie, parseSessionCookie, safeRedirectPath, serializeGrantCookie, serializeSessionCookie, sessionCookieName };
164
+ export { BOARD_ACCESS_COOKIE_NAME, type BoardSession, SESSION_COOKIE_NAME, clearGrantCookie, clearSessionCookie, createSessionRefresher, currentPathFromReferer, getCavunoAppCanonicalRedirectUrl, grantCookieName, isCavunoAppServingHost, isExpiringSoon, parseGrantCookie, parseSessionCookie, safeRedirectPath, serializeGrantCookie, serializeSessionCookie, sessionCookieName };
package/dist/server.js CHANGED
@@ -26,7 +26,9 @@ __export(server_exports, {
26
26
  clearSessionCookie: () => clearSessionCookie,
27
27
  createSessionRefresher: () => createSessionRefresher,
28
28
  currentPathFromReferer: () => currentPathFromReferer,
29
+ getCavunoAppCanonicalRedirectUrl: () => getCavunoAppCanonicalRedirectUrl,
29
30
  grantCookieName: () => grantCookieName,
31
+ isCavunoAppServingHost: () => isCavunoAppServingHost,
30
32
  isExpiringSoon: () => isExpiringSoon,
31
33
  parseGrantCookie: () => parseGrantCookie,
32
34
  parseSessionCookie: () => parseSessionCookie,
@@ -154,6 +156,45 @@ function currentPathFromReferer(referer) {
154
156
  }
155
157
  }
156
158
 
159
+ // src/server/canonical-host-redirect.ts
160
+ var CAVUNO_APP_SERVING_SUFFIX = ".cavuno.app";
161
+ function normalizeHostname(host) {
162
+ if (!host) return "";
163
+ return host.toLowerCase().split(":")[0] ?? "";
164
+ }
165
+ function extractPathAndSearch(url) {
166
+ if (!url) return "/";
167
+ try {
168
+ const parsed = new URL(url, "https://placeholder.invalid");
169
+ return `${parsed.pathname}${parsed.search}` || "/";
170
+ } catch {
171
+ return "/";
172
+ }
173
+ }
174
+ function isCavunoAppServingHost(hostname) {
175
+ const h = hostname.toLowerCase();
176
+ if (!h.endsWith(CAVUNO_APP_SERVING_SUFFIX)) return false;
177
+ if (h === "cavuno.app") return false;
178
+ if (h.startsWith("preview-")) return false;
179
+ return true;
180
+ }
181
+ function getCavunoAppCanonicalRedirectUrl(params) {
182
+ const { currentHost, primaryDomain, requestUrl, defaultPath = "/" } = params;
183
+ const canonical = primaryDomain?.trim().toLowerCase().replace(/\.+$/, "");
184
+ if (!canonical) {
185
+ return null;
186
+ }
187
+ const hostname = normalizeHostname(currentHost);
188
+ if (!hostname || !isCavunoAppServingHost(hostname)) {
189
+ return null;
190
+ }
191
+ if (hostname === canonical) {
192
+ return null;
193
+ }
194
+ const path = requestUrl != null && requestUrl !== "" ? extractPathAndSearch(requestUrl) : defaultPath;
195
+ return `https://${canonical}${path.startsWith("/") ? path : `/${path}`}`;
196
+ }
197
+
157
198
  // src/errors.ts
158
199
  function isBoardApiError(e) {
159
200
  return e instanceof Error && e.name === "BoardApiError" && typeof e.status === "number" && typeof e.code === "string";
package/dist/server.mjs CHANGED
@@ -115,6 +115,45 @@ function currentPathFromReferer(referer) {
115
115
  }
116
116
  }
117
117
 
118
+ // src/server/canonical-host-redirect.ts
119
+ var CAVUNO_APP_SERVING_SUFFIX = ".cavuno.app";
120
+ function normalizeHostname(host) {
121
+ if (!host) return "";
122
+ return host.toLowerCase().split(":")[0] ?? "";
123
+ }
124
+ function extractPathAndSearch(url) {
125
+ if (!url) return "/";
126
+ try {
127
+ const parsed = new URL(url, "https://placeholder.invalid");
128
+ return `${parsed.pathname}${parsed.search}` || "/";
129
+ } catch {
130
+ return "/";
131
+ }
132
+ }
133
+ function isCavunoAppServingHost(hostname) {
134
+ const h = hostname.toLowerCase();
135
+ if (!h.endsWith(CAVUNO_APP_SERVING_SUFFIX)) return false;
136
+ if (h === "cavuno.app") return false;
137
+ if (h.startsWith("preview-")) return false;
138
+ return true;
139
+ }
140
+ function getCavunoAppCanonicalRedirectUrl(params) {
141
+ const { currentHost, primaryDomain, requestUrl, defaultPath = "/" } = params;
142
+ const canonical = primaryDomain?.trim().toLowerCase().replace(/\.+$/, "");
143
+ if (!canonical) {
144
+ return null;
145
+ }
146
+ const hostname = normalizeHostname(currentHost);
147
+ if (!hostname || !isCavunoAppServingHost(hostname)) {
148
+ return null;
149
+ }
150
+ if (hostname === canonical) {
151
+ return null;
152
+ }
153
+ const path = requestUrl != null && requestUrl !== "" ? extractPathAndSearch(requestUrl) : defaultPath;
154
+ return `https://${canonical}${path.startsWith("/") ? path : `/${path}`}`;
155
+ }
156
+
118
157
  // src/errors.ts
119
158
  function isBoardApiError(e) {
120
159
  return e instanceof Error && e.name === "BoardApiError" && typeof e.status === "number" && typeof e.code === "string";
@@ -172,7 +211,9 @@ export {
172
211
  clearSessionCookie,
173
212
  createSessionRefresher,
174
213
  currentPathFromReferer,
214
+ getCavunoAppCanonicalRedirectUrl,
175
215
  grantCookieName,
216
+ isCavunoAppServingHost,
176
217
  isExpiringSoon,
177
218
  parseGrantCookie,
178
219
  parseSessionCookie,
@@ -1,7 +1,9 @@
1
1
  import { BoardSdk } from './index.mjs';
2
- import './jobs-DMH3Ytoq.mjs';
3
- import './board-CBry4f7H.mjs';
4
- import './salaries-01g4wK8y.mjs';
2
+ import './_spec-DRbgPIlN.mjs';
3
+ import './search-DYUQzCq_.mjs';
4
+ import './board-DCiNpgFf.mjs';
5
+ import './jobs-DhePKSRe.mjs';
6
+ import './salaries-D6SUVMmt.mjs';
5
7
 
6
8
  /**
7
9
  * Sitemap primitives — the pure XML + bucket-filename logic behind a board
package/dist/sitemap.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { BoardSdk } from './index.js';
2
- import './jobs-DMH3Ytoq.js';
3
- import './board-tV-zcaHs.js';
4
- import './salaries-CaGlcCC6.js';
2
+ import './_spec-DRbgPIlN.js';
3
+ import './search-BalPAS0P.js';
4
+ import './board-BxPUtrOl.js';
5
+ import './jobs-DAGAVHQL.js';
6
+ import './salaries-cqb78kg0.js';
5
7
 
6
8
  /**
7
9
  * Sitemap primitives — the pure XML + bucket-filename logic behind a board
@@ -0,0 +1,70 @@
1
+ import { S as SuggestionItem, a as SearchSuggestQuery, F as FetchOptions, b as SuggestResult } from './search-DYUQzCq_.mjs';
2
+ import './_spec-DRbgPIlN.mjs';
3
+
4
+ /**
5
+ * `@cavuno/board/suggest` — framework-agnostic headless search-suggest
6
+ * controller (Algolia autocomplete-core pattern).
7
+ *
8
+ * Owns debounce, abort, and stale-drop so tenant frontends don't re-invent
9
+ * them. Patches ship via npm update. Zero runtime deps; isomorphic
10
+ * (`globalThis` timers only — never `window`/`document`).
11
+ *
12
+ * The ONE permitted view-level filter is excluding currently-applied company
13
+ * slugs from results (type `company` only). Wire data is never reshaped
14
+ * otherwise — order stays server-ranked.
15
+ */
16
+
17
+ interface SuggestControllerOptions {
18
+ /** Minimum characters before querying (default 2 — hosted parity). */
19
+ minChars?: number;
20
+ /** Debounce for setQuery → request (default 250ms). */
21
+ debounceMs?: number;
22
+ /** Server `limit` passed through (1–25). */
23
+ limit?: number;
24
+ }
25
+ type SuggestStatus = 'idle' | 'loading' | 'ready' | 'error';
26
+ interface SuggestState {
27
+ readonly query: string;
28
+ /**
29
+ * Server-ranked items. Previous items are RETAINED while a new request
30
+ * is loading (no flash-to-empty — hosted behavior).
31
+ */
32
+ readonly items: readonly SuggestionItem[];
33
+ /**
34
+ * `'loading'` from the moment a qualifying query is set (debounce window
35
+ * included) until that query's request settles. `'idle'` only when the
36
+ * query is empty/below minChars. `'ready'` / `'error'` after settle.
37
+ */
38
+ readonly status: SuggestStatus;
39
+ readonly error: unknown;
40
+ }
41
+ interface SuggestController {
42
+ setQuery(query: string): void;
43
+ /** Company slugs to hide from results (e.g. the currently-applied filter). */
44
+ setExcludedCompanySlugs(slugs: readonly string[]): void;
45
+ getState(): SuggestState;
46
+ /** Returns an unsubscribe function. Compatible with useSyncExternalStore. */
47
+ subscribe(listener: () => void): () => void;
48
+ dispose(): void;
49
+ }
50
+ type SuggestBoard = {
51
+ search: {
52
+ suggest(q?: SearchSuggestQuery, o?: FetchOptions): Promise<SuggestResult>;
53
+ };
54
+ };
55
+ /**
56
+ * Create a headless suggest controller bound to a board client (or any
57
+ * object exposing `search.suggest`).
58
+ *
59
+ * @example
60
+ * import { createBoardClient } from '@cavuno/board';
61
+ * import { createSuggestController } from '@cavuno/board/suggest';
62
+ *
63
+ * const board = createBoardClient({ board: 'pk_…' });
64
+ * const suggest = createSuggestController(board);
65
+ * suggest.subscribe(() => render(suggest.getState()));
66
+ * suggest.setQuery('acme');
67
+ */
68
+ declare function createSuggestController(board: SuggestBoard, options?: SuggestControllerOptions): SuggestController;
69
+
70
+ export { type SuggestController, type SuggestControllerOptions, type SuggestState, type SuggestStatus, createSuggestController };
@@ -0,0 +1,70 @@
1
+ import { S as SuggestionItem, a as SearchSuggestQuery, F as FetchOptions, b as SuggestResult } from './search-BalPAS0P.js';
2
+ import './_spec-DRbgPIlN.js';
3
+
4
+ /**
5
+ * `@cavuno/board/suggest` — framework-agnostic headless search-suggest
6
+ * controller (Algolia autocomplete-core pattern).
7
+ *
8
+ * Owns debounce, abort, and stale-drop so tenant frontends don't re-invent
9
+ * them. Patches ship via npm update. Zero runtime deps; isomorphic
10
+ * (`globalThis` timers only — never `window`/`document`).
11
+ *
12
+ * The ONE permitted view-level filter is excluding currently-applied company
13
+ * slugs from results (type `company` only). Wire data is never reshaped
14
+ * otherwise — order stays server-ranked.
15
+ */
16
+
17
+ interface SuggestControllerOptions {
18
+ /** Minimum characters before querying (default 2 — hosted parity). */
19
+ minChars?: number;
20
+ /** Debounce for setQuery → request (default 250ms). */
21
+ debounceMs?: number;
22
+ /** Server `limit` passed through (1–25). */
23
+ limit?: number;
24
+ }
25
+ type SuggestStatus = 'idle' | 'loading' | 'ready' | 'error';
26
+ interface SuggestState {
27
+ readonly query: string;
28
+ /**
29
+ * Server-ranked items. Previous items are RETAINED while a new request
30
+ * is loading (no flash-to-empty — hosted behavior).
31
+ */
32
+ readonly items: readonly SuggestionItem[];
33
+ /**
34
+ * `'loading'` from the moment a qualifying query is set (debounce window
35
+ * included) until that query's request settles. `'idle'` only when the
36
+ * query is empty/below minChars. `'ready'` / `'error'` after settle.
37
+ */
38
+ readonly status: SuggestStatus;
39
+ readonly error: unknown;
40
+ }
41
+ interface SuggestController {
42
+ setQuery(query: string): void;
43
+ /** Company slugs to hide from results (e.g. the currently-applied filter). */
44
+ setExcludedCompanySlugs(slugs: readonly string[]): void;
45
+ getState(): SuggestState;
46
+ /** Returns an unsubscribe function. Compatible with useSyncExternalStore. */
47
+ subscribe(listener: () => void): () => void;
48
+ dispose(): void;
49
+ }
50
+ type SuggestBoard = {
51
+ search: {
52
+ suggest(q?: SearchSuggestQuery, o?: FetchOptions): Promise<SuggestResult>;
53
+ };
54
+ };
55
+ /**
56
+ * Create a headless suggest controller bound to a board client (or any
57
+ * object exposing `search.suggest`).
58
+ *
59
+ * @example
60
+ * import { createBoardClient } from '@cavuno/board';
61
+ * import { createSuggestController } from '@cavuno/board/suggest';
62
+ *
63
+ * const board = createBoardClient({ board: 'pk_…' });
64
+ * const suggest = createSuggestController(board);
65
+ * suggest.subscribe(() => render(suggest.getState()));
66
+ * suggest.setQuery('acme');
67
+ */
68
+ declare function createSuggestController(board: SuggestBoard, options?: SuggestControllerOptions): SuggestController;
69
+
70
+ export { type SuggestController, type SuggestControllerOptions, type SuggestState, type SuggestStatus, createSuggestController };
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/suggest/index.ts
21
+ var suggest_exports = {};
22
+ __export(suggest_exports, {
23
+ createSuggestController: () => createSuggestController
24
+ });
25
+ module.exports = __toCommonJS(suggest_exports);
26
+ var DEFAULT_MIN_CHARS = 2;
27
+ var DEFAULT_DEBOUNCE_MS = 250;
28
+ function isAbortError(error) {
29
+ return typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "AbortError" || error instanceof Error && error.name === "AbortError";
30
+ }
31
+ function applyExclusions(items, excluded) {
32
+ if (excluded.size === 0) return items.slice();
33
+ return items.filter(
34
+ (item) => !(item.type === "company" && excluded.has(item.slug.toLowerCase()))
35
+ );
36
+ }
37
+ function createSuggestController(board, options) {
38
+ const minChars = options?.minChars ?? DEFAULT_MIN_CHARS;
39
+ const debounceMs = options?.debounceMs ?? DEFAULT_DEBOUNCE_MS;
40
+ const limit = options?.limit;
41
+ let disposed = false;
42
+ let state = {
43
+ query: "",
44
+ items: [],
45
+ status: "idle",
46
+ error: null
47
+ };
48
+ let rawItems = [];
49
+ let excluded = /* @__PURE__ */ new Set();
50
+ let sequence = 0;
51
+ let timer = null;
52
+ let abortController = null;
53
+ const listeners = /* @__PURE__ */ new Set();
54
+ function notify() {
55
+ for (const listener of listeners) listener();
56
+ }
57
+ function setState(next) {
58
+ state = next;
59
+ notify();
60
+ }
61
+ function cancelTimer() {
62
+ if (timer !== null) {
63
+ globalThis.clearTimeout(timer);
64
+ timer = null;
65
+ }
66
+ }
67
+ function abortInFlight() {
68
+ if (abortController) {
69
+ abortController.abort();
70
+ abortController = null;
71
+ }
72
+ }
73
+ function runRequest(query) {
74
+ const seq = ++sequence;
75
+ abortInFlight();
76
+ const controller = new AbortController();
77
+ abortController = controller;
78
+ const suggestQuery = { q: query };
79
+ if (limit !== void 0) suggestQuery.limit = limit;
80
+ void board.search.suggest(suggestQuery, { signal: controller.signal }).then((result) => {
81
+ if (disposed || seq !== sequence) return;
82
+ abortController = null;
83
+ rawItems = result.items;
84
+ setState({
85
+ query: state.query,
86
+ items: applyExclusions(rawItems, excluded),
87
+ status: "ready",
88
+ error: null
89
+ });
90
+ }).catch((error) => {
91
+ if (disposed || seq !== sequence) return;
92
+ if (isAbortError(error)) return;
93
+ abortController = null;
94
+ setState({
95
+ query: state.query,
96
+ items: state.items,
97
+ status: "error",
98
+ error
99
+ });
100
+ });
101
+ }
102
+ return {
103
+ setQuery(query) {
104
+ if (disposed) return;
105
+ const trimmed = query.trim();
106
+ cancelTimer();
107
+ if (trimmed.length < minChars) {
108
+ sequence += 1;
109
+ abortInFlight();
110
+ rawItems = [];
111
+ setState({
112
+ query,
113
+ items: [],
114
+ status: "idle",
115
+ error: null
116
+ });
117
+ return;
118
+ }
119
+ sequence += 1;
120
+ abortInFlight();
121
+ setState({
122
+ query,
123
+ items: state.items,
124
+ status: "loading",
125
+ error: null
126
+ });
127
+ timer = globalThis.setTimeout(() => {
128
+ timer = null;
129
+ if (disposed) return;
130
+ runRequest(trimmed);
131
+ }, debounceMs);
132
+ },
133
+ setExcludedCompanySlugs(slugs) {
134
+ if (disposed) return;
135
+ excluded = new Set(
136
+ slugs.map((s) => s.trim().toLowerCase()).filter(Boolean)
137
+ );
138
+ setState({
139
+ query: state.query,
140
+ items: applyExclusions(rawItems, excluded),
141
+ status: state.status,
142
+ error: state.error
143
+ });
144
+ },
145
+ getState() {
146
+ return state;
147
+ },
148
+ subscribe(listener) {
149
+ if (disposed) return () => {
150
+ };
151
+ listeners.add(listener);
152
+ return () => {
153
+ listeners.delete(listener);
154
+ };
155
+ },
156
+ dispose() {
157
+ if (disposed) return;
158
+ disposed = true;
159
+ cancelTimer();
160
+ abortInFlight();
161
+ sequence += 1;
162
+ listeners.clear();
163
+ }
164
+ };
165
+ }
@@ -0,0 +1,144 @@
1
+ // src/suggest/index.ts
2
+ var DEFAULT_MIN_CHARS = 2;
3
+ var DEFAULT_DEBOUNCE_MS = 250;
4
+ function isAbortError(error) {
5
+ return typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "AbortError" || error instanceof Error && error.name === "AbortError";
6
+ }
7
+ function applyExclusions(items, excluded) {
8
+ if (excluded.size === 0) return items.slice();
9
+ return items.filter(
10
+ (item) => !(item.type === "company" && excluded.has(item.slug.toLowerCase()))
11
+ );
12
+ }
13
+ function createSuggestController(board, options) {
14
+ const minChars = options?.minChars ?? DEFAULT_MIN_CHARS;
15
+ const debounceMs = options?.debounceMs ?? DEFAULT_DEBOUNCE_MS;
16
+ const limit = options?.limit;
17
+ let disposed = false;
18
+ let state = {
19
+ query: "",
20
+ items: [],
21
+ status: "idle",
22
+ error: null
23
+ };
24
+ let rawItems = [];
25
+ let excluded = /* @__PURE__ */ new Set();
26
+ let sequence = 0;
27
+ let timer = null;
28
+ let abortController = null;
29
+ const listeners = /* @__PURE__ */ new Set();
30
+ function notify() {
31
+ for (const listener of listeners) listener();
32
+ }
33
+ function setState(next) {
34
+ state = next;
35
+ notify();
36
+ }
37
+ function cancelTimer() {
38
+ if (timer !== null) {
39
+ globalThis.clearTimeout(timer);
40
+ timer = null;
41
+ }
42
+ }
43
+ function abortInFlight() {
44
+ if (abortController) {
45
+ abortController.abort();
46
+ abortController = null;
47
+ }
48
+ }
49
+ function runRequest(query) {
50
+ const seq = ++sequence;
51
+ abortInFlight();
52
+ const controller = new AbortController();
53
+ abortController = controller;
54
+ const suggestQuery = { q: query };
55
+ if (limit !== void 0) suggestQuery.limit = limit;
56
+ void board.search.suggest(suggestQuery, { signal: controller.signal }).then((result) => {
57
+ if (disposed || seq !== sequence) return;
58
+ abortController = null;
59
+ rawItems = result.items;
60
+ setState({
61
+ query: state.query,
62
+ items: applyExclusions(rawItems, excluded),
63
+ status: "ready",
64
+ error: null
65
+ });
66
+ }).catch((error) => {
67
+ if (disposed || seq !== sequence) return;
68
+ if (isAbortError(error)) return;
69
+ abortController = null;
70
+ setState({
71
+ query: state.query,
72
+ items: state.items,
73
+ status: "error",
74
+ error
75
+ });
76
+ });
77
+ }
78
+ return {
79
+ setQuery(query) {
80
+ if (disposed) return;
81
+ const trimmed = query.trim();
82
+ cancelTimer();
83
+ if (trimmed.length < minChars) {
84
+ sequence += 1;
85
+ abortInFlight();
86
+ rawItems = [];
87
+ setState({
88
+ query,
89
+ items: [],
90
+ status: "idle",
91
+ error: null
92
+ });
93
+ return;
94
+ }
95
+ sequence += 1;
96
+ abortInFlight();
97
+ setState({
98
+ query,
99
+ items: state.items,
100
+ status: "loading",
101
+ error: null
102
+ });
103
+ timer = globalThis.setTimeout(() => {
104
+ timer = null;
105
+ if (disposed) return;
106
+ runRequest(trimmed);
107
+ }, debounceMs);
108
+ },
109
+ setExcludedCompanySlugs(slugs) {
110
+ if (disposed) return;
111
+ excluded = new Set(
112
+ slugs.map((s) => s.trim().toLowerCase()).filter(Boolean)
113
+ );
114
+ setState({
115
+ query: state.query,
116
+ items: applyExclusions(rawItems, excluded),
117
+ status: state.status,
118
+ error: state.error
119
+ });
120
+ },
121
+ getState() {
122
+ return state;
123
+ },
124
+ subscribe(listener) {
125
+ if (disposed) return () => {
126
+ };
127
+ listeners.add(listener);
128
+ return () => {
129
+ listeners.delete(listener);
130
+ };
131
+ },
132
+ dispose() {
133
+ if (disposed) return;
134
+ disposed = true;
135
+ cancelTimer();
136
+ abortInFlight();
137
+ sequence += 1;
138
+ listeners.clear();
139
+ }
140
+ };
141
+ }
142
+ export {
143
+ createSuggestController
144
+ };