@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.
- package/README.md +7 -0
- package/dist/{jobs-DMH3Ytoq.d.mts → _spec-DRbgPIlN.d.mts} +404 -96
- package/dist/{jobs-DMH3Ytoq.d.ts → _spec-DRbgPIlN.d.ts} +404 -96
- package/dist/bin.mjs +242 -18
- package/dist/{board-CBry4f7H.d.mts → board-BxPUtrOl.d.ts} +1 -1
- package/dist/{board-tV-zcaHs.d.ts → board-DCiNpgFf.d.mts} +1 -1
- package/dist/doctor.js +238 -14
- package/dist/doctor.mjs +238 -14
- package/dist/filters.d.mts +18 -4
- package/dist/filters.d.ts +18 -4
- package/dist/filters.js +17 -0
- package/dist/filters.mjs +17 -0
- package/dist/format.d.mts +3 -2
- package/dist/format.d.ts +3 -2
- package/dist/index.d.mts +62 -73
- package/dist/index.d.ts +62 -73
- package/dist/index.js +71 -1
- package/dist/index.mjs +71 -1
- package/dist/jobs-DAGAVHQL.d.ts +105 -0
- package/dist/jobs-DhePKSRe.d.mts +105 -0
- package/dist/{salaries-CaGlcCC6.d.ts → salaries-D6SUVMmt.d.mts} +2 -1
- package/dist/{salaries-01g4wK8y.d.mts → salaries-cqb78kg0.d.ts} +2 -1
- package/dist/search-BalPAS0P.d.ts +81 -0
- package/dist/search-DYUQzCq_.d.mts +81 -0
- package/dist/seo.d.mts +4 -3
- package/dist/seo.d.ts +4 -3
- package/dist/server.d.mts +56 -4
- package/dist/server.d.ts +56 -4
- package/dist/server.js +41 -0
- package/dist/server.mjs +41 -0
- package/dist/sitemap.d.mts +5 -3
- package/dist/sitemap.d.ts +5 -3
- package/dist/suggest.d.mts +70 -0
- package/dist/suggest.d.ts +70 -0
- package/dist/suggest.js +165 -0
- package/dist/suggest.mjs +144 -0
- package/dist/theme.d.mts +49 -1
- package/dist/theme.d.ts +49 -1
- package/dist/theme.js +60 -26
- package/dist/theme.mjs +60 -26
- package/package.json +11 -1
- package/skills/cavuno-board-filters/SKILL.md +11 -4
- package/skills/cavuno-board-jobs/SKILL.md +18 -2
- package/skills/cavuno-board-suggest/SKILL.md +119 -0
- 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 './
|
|
3
|
-
import './
|
|
4
|
-
import './
|
|
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,
|
package/dist/sitemap.d.mts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { BoardSdk } from './index.mjs';
|
|
2
|
-
import './
|
|
3
|
-
import './
|
|
4
|
-
import './
|
|
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 './
|
|
3
|
-
import './
|
|
4
|
-
import './
|
|
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 };
|
package/dist/suggest.js
ADDED
|
@@ -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
|
+
}
|
package/dist/suggest.mjs
ADDED
|
@@ -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
|
+
};
|