@burdenoff/website-sdk 2026.922.5 → 2026.923.2

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.
@@ -1,4 +1,4 @@
1
- import { b as ExploreLimits, c as ExploreCatalog, d as ExploreMessage, e as ExploreFeedbackRating, f as ExploreFeedbackReason } from './explore-types-DvQPiL1t.mjs';
1
+ import { b as ExploreLimits, c as ExploreCatalog, d as ExploreMessage, e as ExploreFeedbackRating, f as ExploreFeedbackReason } from './explore-types-B5sdOC5M.mjs';
2
2
 
3
3
  /** What travels on the wire — mirrors `ExploreAttachmentInput`. */
4
4
  interface ExploreAttachmentPayload {
@@ -34,6 +34,34 @@ interface ExploreChatError {
34
34
  /** Whether re-sending the same message could plausibly succeed. */
35
35
  retryable: boolean;
36
36
  }
37
+ /**
38
+ * What came of handing the conversation to a person.
39
+ *
40
+ * `sent` and `already-handed-over` are BOTH successes. The second is the double-clicked
41
+ * button, the retried request and the reloaded page: the server keeps one handover per
42
+ * conversation, so a repeat submit returns the first one rather than filing a second ticket.
43
+ * A visitor who is worried enough to ask for a human must never be told "you already did
44
+ * that" — the conversation is with a person either way, and the UI says so either way.
45
+ */
46
+ type ExploreEscalateOutcome = "sent" | "already-handed-over"
47
+ /** The SERVER rejected the address (`INVALID_EMAIL`), after the client let it through. */
48
+ | "invalid-email" | "rate-limited" | "captcha"
49
+ /** No conversation token — nothing to hand over. The form should not have been offered. */
50
+ | "no-conversation" | "failed";
51
+ interface ExploreEscalateResult {
52
+ outcome: ExploreEscalateOutcome;
53
+ /**
54
+ * The address the SERVER says the reply is going to, on `sent` and `already-handed-over`;
55
+ * null otherwise.
56
+ *
57
+ * On `already-handed-over` this is the address the FIRST handover used, which may not be
58
+ * the one just typed — and it is the one the reply actually reaches, so it is the one to
59
+ * show back.
60
+ */
61
+ email: string | null;
62
+ /** When the handover was filed, as the server holds it. Null unless it exists. */
63
+ createdAt: string | null;
64
+ }
37
65
  interface UseExploreChatOptions {
38
66
  /** Slug of the product site this page is on — a soft hint to the harness. */
39
67
  productSlug?: string;
@@ -116,6 +144,30 @@ interface UseExploreChatResult {
116
144
  reason?: ExploreFeedbackReason | null;
117
145
  comment?: string | null;
118
146
  }) => Promise<boolean>;
147
+ /**
148
+ * Hand this conversation to a person, so they pick it up with the transcript attached.
149
+ *
150
+ * Awaited, like {@link submitFeedback} and for the same reason — the visitor is watching
151
+ * the control they pressed — but this one has more to say than true/false: "we could not
152
+ * deliver to that address", "you are sending these too fast" and "it is already with a
153
+ * human" are three different things to a worried visitor, and one boolean flattens them
154
+ * into a shrug. It never throws; every outcome comes back in
155
+ * {@link ExploreEscalateResult}.
156
+ *
157
+ * `email` is personal data. It goes into the mutation variables and nowhere else — not
158
+ * into `messages` (which is what this hook persists), not into `onSend`, not into any
159
+ * analytics call.
160
+ */
161
+ escalate: (email: string, note?: string) => Promise<ExploreEscalateResult>;
162
+ /**
163
+ * Whether there is a conversation a person could actually be handed.
164
+ *
165
+ * False on a fresh page and false in a read-only shared view (a `?c=` secret is not a
166
+ * conversation token), which are exactly the cases where {@link escalate} could only
167
+ * answer `no-conversation`. Offer the form on this, rather than offering one that cannot
168
+ * work.
169
+ */
170
+ canEscalate: boolean;
119
171
  /**
120
172
  * Mint a read-only link to this conversation, or null when there is nothing to share yet.
121
173
  * The secret returned is not the conversation token and cannot continue the thread.
@@ -141,11 +193,6 @@ declare const FALLBACK_EXPLORE_LIMITS: ExploreLimits;
141
193
  * brochure without a single copy change being reviewed.
142
194
  */
143
195
  declare const DEFAULT_EXPLORE_EXAMPLE_PROMPTS: string[];
144
- /**
145
- * Map a `extensions.code` (or a client-side transport code) onto the bucket the
146
- * UI renders. Unknown codes are treated as retryable — a transient backend
147
- * condition we have not enumerated is far more likely than a permanent one.
148
- */
149
196
  declare function classifyExploreError(code: string | null | undefined, serverMessage?: string | null): ExploreChatError;
150
197
  /** One past conversation, as shown in the history list and restored on click. */
151
198
  interface ArchivedExploreConversation {
@@ -194,4 +241,4 @@ declare function useSpeechOutput({ lang, }?: UseSpeechOutputOptions): UseSpeechO
194
241
  */
195
242
  declare function useProgressiveReveal(text: string, settled: boolean): string;
196
243
 
197
- export { DEFAULT_EXPLORE_EXAMPLE_PROMPTS as D, type ExploreChatError as E, FALLBACK_EXPLORE_LIMITS as F, type UseExploreChatOptions as U, type ExploreChatErrorKind as a, type ExploreChatPhase as b, type UseExploreChatResult as c, type UseSpeechOutputOptions as d, type UseSpeechOutputResult as e, classifyExploreError as f, useProgressiveReveal as g, useSpeechOutput as h, stopSpeaking as s, useExploreChat as u };
244
+ export { DEFAULT_EXPLORE_EXAMPLE_PROMPTS as D, type ExploreChatError as E, FALLBACK_EXPLORE_LIMITS as F, type UseExploreChatOptions as U, type ExploreChatErrorKind as a, type ExploreChatPhase as b, type ExploreEscalateOutcome as c, type ExploreEscalateResult as d, type UseExploreChatResult as e, type UseSpeechOutputOptions as f, type UseSpeechOutputResult as g, classifyExploreError as h, useProgressiveReveal as i, useSpeechOutput as j, stopSpeaking as s, useExploreChat as u };
@@ -1,4 +1,4 @@
1
- import { b as ExploreLimits, c as ExploreCatalog, d as ExploreMessage, e as ExploreFeedbackRating, f as ExploreFeedbackReason } from './explore-types-DvQPiL1t.js';
1
+ import { b as ExploreLimits, c as ExploreCatalog, d as ExploreMessage, e as ExploreFeedbackRating, f as ExploreFeedbackReason } from './explore-types-B5sdOC5M.js';
2
2
 
3
3
  /** What travels on the wire — mirrors `ExploreAttachmentInput`. */
4
4
  interface ExploreAttachmentPayload {
@@ -34,6 +34,34 @@ interface ExploreChatError {
34
34
  /** Whether re-sending the same message could plausibly succeed. */
35
35
  retryable: boolean;
36
36
  }
37
+ /**
38
+ * What came of handing the conversation to a person.
39
+ *
40
+ * `sent` and `already-handed-over` are BOTH successes. The second is the double-clicked
41
+ * button, the retried request and the reloaded page: the server keeps one handover per
42
+ * conversation, so a repeat submit returns the first one rather than filing a second ticket.
43
+ * A visitor who is worried enough to ask for a human must never be told "you already did
44
+ * that" — the conversation is with a person either way, and the UI says so either way.
45
+ */
46
+ type ExploreEscalateOutcome = "sent" | "already-handed-over"
47
+ /** The SERVER rejected the address (`INVALID_EMAIL`), after the client let it through. */
48
+ | "invalid-email" | "rate-limited" | "captcha"
49
+ /** No conversation token — nothing to hand over. The form should not have been offered. */
50
+ | "no-conversation" | "failed";
51
+ interface ExploreEscalateResult {
52
+ outcome: ExploreEscalateOutcome;
53
+ /**
54
+ * The address the SERVER says the reply is going to, on `sent` and `already-handed-over`;
55
+ * null otherwise.
56
+ *
57
+ * On `already-handed-over` this is the address the FIRST handover used, which may not be
58
+ * the one just typed — and it is the one the reply actually reaches, so it is the one to
59
+ * show back.
60
+ */
61
+ email: string | null;
62
+ /** When the handover was filed, as the server holds it. Null unless it exists. */
63
+ createdAt: string | null;
64
+ }
37
65
  interface UseExploreChatOptions {
38
66
  /** Slug of the product site this page is on — a soft hint to the harness. */
39
67
  productSlug?: string;
@@ -116,6 +144,30 @@ interface UseExploreChatResult {
116
144
  reason?: ExploreFeedbackReason | null;
117
145
  comment?: string | null;
118
146
  }) => Promise<boolean>;
147
+ /**
148
+ * Hand this conversation to a person, so they pick it up with the transcript attached.
149
+ *
150
+ * Awaited, like {@link submitFeedback} and for the same reason — the visitor is watching
151
+ * the control they pressed — but this one has more to say than true/false: "we could not
152
+ * deliver to that address", "you are sending these too fast" and "it is already with a
153
+ * human" are three different things to a worried visitor, and one boolean flattens them
154
+ * into a shrug. It never throws; every outcome comes back in
155
+ * {@link ExploreEscalateResult}.
156
+ *
157
+ * `email` is personal data. It goes into the mutation variables and nowhere else — not
158
+ * into `messages` (which is what this hook persists), not into `onSend`, not into any
159
+ * analytics call.
160
+ */
161
+ escalate: (email: string, note?: string) => Promise<ExploreEscalateResult>;
162
+ /**
163
+ * Whether there is a conversation a person could actually be handed.
164
+ *
165
+ * False on a fresh page and false in a read-only shared view (a `?c=` secret is not a
166
+ * conversation token), which are exactly the cases where {@link escalate} could only
167
+ * answer `no-conversation`. Offer the form on this, rather than offering one that cannot
168
+ * work.
169
+ */
170
+ canEscalate: boolean;
119
171
  /**
120
172
  * Mint a read-only link to this conversation, or null when there is nothing to share yet.
121
173
  * The secret returned is not the conversation token and cannot continue the thread.
@@ -141,11 +193,6 @@ declare const FALLBACK_EXPLORE_LIMITS: ExploreLimits;
141
193
  * brochure without a single copy change being reviewed.
142
194
  */
143
195
  declare const DEFAULT_EXPLORE_EXAMPLE_PROMPTS: string[];
144
- /**
145
- * Map a `extensions.code` (or a client-side transport code) onto the bucket the
146
- * UI renders. Unknown codes are treated as retryable — a transient backend
147
- * condition we have not enumerated is far more likely than a permanent one.
148
- */
149
196
  declare function classifyExploreError(code: string | null | undefined, serverMessage?: string | null): ExploreChatError;
150
197
  /** One past conversation, as shown in the history list and restored on click. */
151
198
  interface ArchivedExploreConversation {
@@ -194,4 +241,4 @@ declare function useSpeechOutput({ lang, }?: UseSpeechOutputOptions): UseSpeechO
194
241
  */
195
242
  declare function useProgressiveReveal(text: string, settled: boolean): string;
196
243
 
197
- export { DEFAULT_EXPLORE_EXAMPLE_PROMPTS as D, type ExploreChatError as E, FALLBACK_EXPLORE_LIMITS as F, type UseExploreChatOptions as U, type ExploreChatErrorKind as a, type ExploreChatPhase as b, type UseExploreChatResult as c, type UseSpeechOutputOptions as d, type UseSpeechOutputResult as e, classifyExploreError as f, useProgressiveReveal as g, useSpeechOutput as h, stopSpeaking as s, useExploreChat as u };
244
+ export { DEFAULT_EXPLORE_EXAMPLE_PROMPTS as D, type ExploreChatError as E, FALLBACK_EXPLORE_LIMITS as F, type UseExploreChatOptions as U, type ExploreChatErrorKind as a, type ExploreChatPhase as b, type ExploreEscalateOutcome as c, type ExploreEscalateResult as d, type UseExploreChatResult as e, type UseSpeechOutputOptions as f, type UseSpeechOutputResult as g, classifyExploreError as h, useProgressiveReveal as i, useSpeechOutput as j, stopSpeaking as s, useExploreChat as u };
@@ -66,4 +66,58 @@ declare function loadRecaptchaV3(siteKey: string): Promise<RecaptchaV3>;
66
66
  */
67
67
  declare function getRecaptchaV3Token(siteKey: string, action?: string): Promise<string>;
68
68
 
69
- export { EXPLORE_RECAPTCHA_ACTION, type RecaptchaV3, cn, getRecaptchaV3Token, loadRecaptchaV3 };
69
+ /**
70
+ * Which routes a host site should render WITHOUT its marketing footer.
71
+ *
72
+ * ## Why this lives in the SDK
73
+ *
74
+ * It was written 39 times. The footer belongs to each website, so the obvious
75
+ * place for the rule is each website — and eight agents asked to add it produced
76
+ * five different helpers (`footerless-routes.ts`, `footer-visibility.ts`,
77
+ * `footer-routes.ts`, `explore-route.ts`, plus six copies inlined straight into
78
+ * a layout). All five behave the same today. The next person to change the rule
79
+ * has to find all five, and will not.
80
+ *
81
+ * The rule is really a property of the SDK's own surfaces: `/explore` is
82
+ * footerless because `ExplorePage` sizes itself to one viewport, which is a fact
83
+ * about this package, not about any site. So it is declared here, once, and the
84
+ * sites ask.
85
+ *
86
+ * ## Why `/explore` and nothing else
87
+ *
88
+ * `ExplorePage` renders a full-viewport chat pane (`100dvh` minus the site
89
+ * chrome above it), so anything after it is below the fold by construction. On a
90
+ * phone these footers are 1100-1330px — roughly 70% of the document — and that
91
+ * bulk is what allowed a restored scroll offset to open the page on nothing but
92
+ * footer, with zero pixels of the chat on screen.
93
+ *
94
+ * The SDK's OTHER pages are the opposite case and must keep their footer:
95
+ * `/concepts`, `/concept/:slug`, `/answers` and `/answer/:slug` are ordinary
96
+ * indexed content pages whose footer carries the site's internal links.
97
+ * Stripping it would cost hundreds of pages their internal linking, and nobody
98
+ * would notice for weeks.
99
+ */
100
+ /** Routes that render without the site footer. Exact paths, not prefixes. */
101
+ declare const FOOTERLESS_ROUTES: readonly string[];
102
+ /**
103
+ * Normalise a router pathname for exact comparison.
104
+ *
105
+ * React Router matches case-insensitively (`caseSensitive` defaults to false)
106
+ * and tolerates a trailing slash, so `/Explore/` reaches the same route element
107
+ * as `/explore` and has to be treated the same way here. Half the fleet's
108
+ * hand-written versions compared case-sensitively and would have rendered a
109
+ * footer under the chat pane on `/Explore`.
110
+ */
111
+ declare function normalizeRoutePath(pathname: string): string;
112
+ /**
113
+ * True when this pathname is one of the SDK's full-screen surfaces.
114
+ *
115
+ * Deliberately an exact-set test. `startsWith("/explore")` would also swallow a
116
+ * future `/explore-beta`, and `includes("explore")` would swallow far more —
117
+ * including `/concept/explore-your-data`, which is a real shape of concept slug.
118
+ */
119
+ declare function isFooterlessPath(pathname: string): boolean;
120
+ /** Inverse of {@link isFooterlessPath}, for layouts that read better positively. */
121
+ declare function shouldRenderSiteFooter(pathname: string): boolean;
122
+
123
+ export { EXPLORE_RECAPTCHA_ACTION, FOOTERLESS_ROUTES, type RecaptchaV3, cn, getRecaptchaV3Token, isFooterlessPath, loadRecaptchaV3, normalizeRoutePath, shouldRenderSiteFooter };
@@ -66,4 +66,58 @@ declare function loadRecaptchaV3(siteKey: string): Promise<RecaptchaV3>;
66
66
  */
67
67
  declare function getRecaptchaV3Token(siteKey: string, action?: string): Promise<string>;
68
68
 
69
- export { EXPLORE_RECAPTCHA_ACTION, type RecaptchaV3, cn, getRecaptchaV3Token, loadRecaptchaV3 };
69
+ /**
70
+ * Which routes a host site should render WITHOUT its marketing footer.
71
+ *
72
+ * ## Why this lives in the SDK
73
+ *
74
+ * It was written 39 times. The footer belongs to each website, so the obvious
75
+ * place for the rule is each website — and eight agents asked to add it produced
76
+ * five different helpers (`footerless-routes.ts`, `footer-visibility.ts`,
77
+ * `footer-routes.ts`, `explore-route.ts`, plus six copies inlined straight into
78
+ * a layout). All five behave the same today. The next person to change the rule
79
+ * has to find all five, and will not.
80
+ *
81
+ * The rule is really a property of the SDK's own surfaces: `/explore` is
82
+ * footerless because `ExplorePage` sizes itself to one viewport, which is a fact
83
+ * about this package, not about any site. So it is declared here, once, and the
84
+ * sites ask.
85
+ *
86
+ * ## Why `/explore` and nothing else
87
+ *
88
+ * `ExplorePage` renders a full-viewport chat pane (`100dvh` minus the site
89
+ * chrome above it), so anything after it is below the fold by construction. On a
90
+ * phone these footers are 1100-1330px — roughly 70% of the document — and that
91
+ * bulk is what allowed a restored scroll offset to open the page on nothing but
92
+ * footer, with zero pixels of the chat on screen.
93
+ *
94
+ * The SDK's OTHER pages are the opposite case and must keep their footer:
95
+ * `/concepts`, `/concept/:slug`, `/answers` and `/answer/:slug` are ordinary
96
+ * indexed content pages whose footer carries the site's internal links.
97
+ * Stripping it would cost hundreds of pages their internal linking, and nobody
98
+ * would notice for weeks.
99
+ */
100
+ /** Routes that render without the site footer. Exact paths, not prefixes. */
101
+ declare const FOOTERLESS_ROUTES: readonly string[];
102
+ /**
103
+ * Normalise a router pathname for exact comparison.
104
+ *
105
+ * React Router matches case-insensitively (`caseSensitive` defaults to false)
106
+ * and tolerates a trailing slash, so `/Explore/` reaches the same route element
107
+ * as `/explore` and has to be treated the same way here. Half the fleet's
108
+ * hand-written versions compared case-sensitively and would have rendered a
109
+ * footer under the chat pane on `/Explore`.
110
+ */
111
+ declare function normalizeRoutePath(pathname: string): string;
112
+ /**
113
+ * True when this pathname is one of the SDK's full-screen surfaces.
114
+ *
115
+ * Deliberately an exact-set test. `startsWith("/explore")` would also swallow a
116
+ * future `/explore-beta`, and `includes("explore")` would swallow far more —
117
+ * including `/concept/explore-your-data`, which is a real shape of concept slug.
118
+ */
119
+ declare function isFooterlessPath(pathname: string): boolean;
120
+ /** Inverse of {@link isFooterlessPath}, for layouts that read better positively. */
121
+ declare function shouldRenderSiteFooter(pathname: string): boolean;
122
+
123
+ export { EXPLORE_RECAPTCHA_ACTION, FOOTERLESS_ROUTES, type RecaptchaV3, cn, getRecaptchaV3Token, isFooterlessPath, loadRecaptchaV3, normalizeRoutePath, shouldRenderSiteFooter };
@@ -74,9 +74,26 @@ async function getRecaptchaV3Token(siteKey, action = EXPLORE_RECAPTCHA_ACTION) {
74
74
  return api.execute(siteKey, { action });
75
75
  }
76
76
 
77
+ // src/utils/site-chrome.ts
78
+ var FOOTERLESS_ROUTES = ["/explore"];
79
+ function normalizeRoutePath(pathname) {
80
+ const trimmed = pathname.toLowerCase().replace(/\/+$/, "");
81
+ return trimmed === "" ? "/" : trimmed;
82
+ }
83
+ function isFooterlessPath(pathname) {
84
+ return FOOTERLESS_ROUTES.includes(normalizeRoutePath(pathname));
85
+ }
86
+ function shouldRenderSiteFooter(pathname) {
87
+ return !isFooterlessPath(pathname);
88
+ }
89
+
77
90
  exports.EXPLORE_RECAPTCHA_ACTION = EXPLORE_RECAPTCHA_ACTION;
91
+ exports.FOOTERLESS_ROUTES = FOOTERLESS_ROUTES;
78
92
  exports.cn = cn;
79
93
  exports.getRecaptchaV3Token = getRecaptchaV3Token;
94
+ exports.isFooterlessPath = isFooterlessPath;
80
95
  exports.loadRecaptchaV3 = loadRecaptchaV3;
96
+ exports.normalizeRoutePath = normalizeRoutePath;
97
+ exports.shouldRenderSiteFooter = shouldRenderSiteFooter;
81
98
  //# sourceMappingURL=index.js.map
82
99
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/cn.ts","../../src/utils/recaptcha-v3.ts"],"names":["twMerge","clsx"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Utility function to merge Tailwind CSS classes\n * Combines clsx and tailwind-merge for optimal class handling\n */\n\nimport { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Merge multiple class names intelligently\n * - Handles conditional classes via clsx\n * - Deduplicates and resolves Tailwind conflicts via tailwind-merge\n *\n * @example\n * ```ts\n * cn('px-4 py-2', 'bg-blue-500', { 'text-white': isActive })\n * // => 'px-4 py-2 bg-blue-500 text-white' (if isActive is true)\n * ```\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","/**\n * reCAPTCHA v3 (invisible, score-based) loader and token minter.\n *\n * Deliberately dependency-free: the SDK already ships the `react-google-recaptcha`\n * peer for the **v2 checkbox** used by `ContactPage`, and the two widgets cannot\n * share a script tag — v3 needs `?render=<siteKey>` on the URL, v2 must not have\n * it. This module injects its own v3 script and leaves the v2 integration alone.\n *\n * The Explore chat turns v3 on purely from the backend catalog\n * (`exploreCatalog.captchaRequired && recaptchaSiteKey`), so nothing is loaded\n * and no third-party request is made on sites where captcha is off.\n */\n\n/** The slice of the `grecaptcha` global this module uses. */\nexport interface RecaptchaV3 {\n /** Runs the callback once the API is fully initialised. */\n ready(callback: () => void): void;\n /** Mints a single-use token for `action`. */\n execute(siteKey: string, options: { action: string }): Promise<string>;\n}\n\ndeclare global {\n interface Window {\n grecaptcha?: RecaptchaV3;\n }\n}\n\nconst SCRIPT_ID = \"boff-recaptcha-v3\";\nconst SCRIPT_SRC = \"https://www.google.com/recaptcha/api.js?render=\";\n\n/** Default action name reported to reCAPTCHA for Explore chat submissions. */\nexport const EXPLORE_RECAPTCHA_ACTION = \"explore_chat\";\n\n/**\n * One in-flight/settled promise per site key. Guarantees the script tag is\n * injected at most once even when several components mount at the same time,\n * and lets later callers await the same load instead of racing it.\n */\nconst loaders = new Map<string, Promise<RecaptchaV3>>();\n\nfunction isBrowser(): boolean {\n return typeof window !== \"undefined\" && typeof document !== \"undefined\";\n}\n\n/**\n * Load the reCAPTCHA v3 script for `siteKey` and resolve once `grecaptcha` is\n * ready to mint tokens.\n *\n * - Injects the script at most once per site key (subsequent calls reuse the\n * same promise).\n * - Adopts an already-present script tag (e.g. injected by the host site)\n * rather than adding a second one.\n * - Rejects rather than hanging when the script fails to load, so the caller\n * can decide whether to submit without a token or surface an error.\n */\nexport function loadRecaptchaV3(siteKey: string): Promise<RecaptchaV3> {\n if (!siteKey) {\n return Promise.reject(new Error(\"reCAPTCHA site key is required\"));\n }\n if (!isBrowser()) {\n return Promise.reject(\n new Error(\"reCAPTCHA v3 can only be loaded in a browser\"),\n );\n }\n\n const existing = loaders.get(siteKey);\n if (existing) return existing;\n\n const promise = new Promise<RecaptchaV3>((resolve, reject) => {\n const ready = () => {\n const api = window.grecaptcha;\n if (!api) {\n reject(new Error(\"reCAPTCHA loaded but grecaptcha is unavailable\"));\n return;\n }\n // `ready` queues until the API has finished initialising internally;\n // calling `execute` before that throws.\n api.ready(() => resolve(api));\n };\n\n // Already loaded by this module, another SDK instance, or the host site.\n if (window.grecaptcha) {\n ready();\n return;\n }\n\n const scriptId = `${SCRIPT_ID}-${siteKey}`;\n const found = document.getElementById(scriptId);\n if (found) {\n found.addEventListener(\"load\", ready, { once: true });\n found.addEventListener(\n \"error\",\n () => reject(new Error(\"Failed to load reCAPTCHA v3\")),\n { once: true },\n );\n return;\n }\n\n const script = document.createElement(\"script\");\n script.id = scriptId;\n script.src = `${SCRIPT_SRC}${encodeURIComponent(siteKey)}`;\n script.async = true;\n script.defer = true;\n script.addEventListener(\"load\", ready, { once: true });\n script.addEventListener(\n \"error\",\n () => {\n // Drop the memo so a later attempt can retry a transient CDN failure\n // instead of being stuck with a permanently rejected promise.\n loaders.delete(siteKey);\n reject(new Error(\"Failed to load reCAPTCHA v3\"));\n },\n { once: true },\n );\n document.head.appendChild(script);\n });\n\n loaders.set(siteKey, promise);\n return promise;\n}\n\n/**\n * Mint a single-use reCAPTCHA v3 token, loading the script on first use.\n *\n * Tokens are valid for ~2 minutes and are consumed by one verification, so\n * this must be called per submission — never cached.\n */\nexport async function getRecaptchaV3Token(\n siteKey: string,\n action: string = EXPLORE_RECAPTCHA_ACTION,\n): Promise<string> {\n const api = await loadRecaptchaV3(siteKey);\n return api.execute(siteKey, { action });\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/cn.ts","../../src/utils/recaptcha-v3.ts","../../src/utils/site-chrome.ts"],"names":["twMerge","clsx"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Utility function to merge Tailwind CSS classes\n * Combines clsx and tailwind-merge for optimal class handling\n */\n\nimport { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Merge multiple class names intelligently\n * - Handles conditional classes via clsx\n * - Deduplicates and resolves Tailwind conflicts via tailwind-merge\n *\n * @example\n * ```ts\n * cn('px-4 py-2', 'bg-blue-500', { 'text-white': isActive })\n * // => 'px-4 py-2 bg-blue-500 text-white' (if isActive is true)\n * ```\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","/**\n * reCAPTCHA v3 (invisible, score-based) loader and token minter.\n *\n * Deliberately dependency-free: the SDK already ships the `react-google-recaptcha`\n * peer for the **v2 checkbox** used by `ContactPage`, and the two widgets cannot\n * share a script tag — v3 needs `?render=<siteKey>` on the URL, v2 must not have\n * it. This module injects its own v3 script and leaves the v2 integration alone.\n *\n * The Explore chat turns v3 on purely from the backend catalog\n * (`exploreCatalog.captchaRequired && recaptchaSiteKey`), so nothing is loaded\n * and no third-party request is made on sites where captcha is off.\n */\n\n/** The slice of the `grecaptcha` global this module uses. */\nexport interface RecaptchaV3 {\n /** Runs the callback once the API is fully initialised. */\n ready(callback: () => void): void;\n /** Mints a single-use token for `action`. */\n execute(siteKey: string, options: { action: string }): Promise<string>;\n}\n\ndeclare global {\n interface Window {\n grecaptcha?: RecaptchaV3;\n }\n}\n\nconst SCRIPT_ID = \"boff-recaptcha-v3\";\nconst SCRIPT_SRC = \"https://www.google.com/recaptcha/api.js?render=\";\n\n/** Default action name reported to reCAPTCHA for Explore chat submissions. */\nexport const EXPLORE_RECAPTCHA_ACTION = \"explore_chat\";\n\n/**\n * One in-flight/settled promise per site key. Guarantees the script tag is\n * injected at most once even when several components mount at the same time,\n * and lets later callers await the same load instead of racing it.\n */\nconst loaders = new Map<string, Promise<RecaptchaV3>>();\n\nfunction isBrowser(): boolean {\n return typeof window !== \"undefined\" && typeof document !== \"undefined\";\n}\n\n/**\n * Load the reCAPTCHA v3 script for `siteKey` and resolve once `grecaptcha` is\n * ready to mint tokens.\n *\n * - Injects the script at most once per site key (subsequent calls reuse the\n * same promise).\n * - Adopts an already-present script tag (e.g. injected by the host site)\n * rather than adding a second one.\n * - Rejects rather than hanging when the script fails to load, so the caller\n * can decide whether to submit without a token or surface an error.\n */\nexport function loadRecaptchaV3(siteKey: string): Promise<RecaptchaV3> {\n if (!siteKey) {\n return Promise.reject(new Error(\"reCAPTCHA site key is required\"));\n }\n if (!isBrowser()) {\n return Promise.reject(\n new Error(\"reCAPTCHA v3 can only be loaded in a browser\"),\n );\n }\n\n const existing = loaders.get(siteKey);\n if (existing) return existing;\n\n const promise = new Promise<RecaptchaV3>((resolve, reject) => {\n const ready = () => {\n const api = window.grecaptcha;\n if (!api) {\n reject(new Error(\"reCAPTCHA loaded but grecaptcha is unavailable\"));\n return;\n }\n // `ready` queues until the API has finished initialising internally;\n // calling `execute` before that throws.\n api.ready(() => resolve(api));\n };\n\n // Already loaded by this module, another SDK instance, or the host site.\n if (window.grecaptcha) {\n ready();\n return;\n }\n\n const scriptId = `${SCRIPT_ID}-${siteKey}`;\n const found = document.getElementById(scriptId);\n if (found) {\n found.addEventListener(\"load\", ready, { once: true });\n found.addEventListener(\n \"error\",\n () => reject(new Error(\"Failed to load reCAPTCHA v3\")),\n { once: true },\n );\n return;\n }\n\n const script = document.createElement(\"script\");\n script.id = scriptId;\n script.src = `${SCRIPT_SRC}${encodeURIComponent(siteKey)}`;\n script.async = true;\n script.defer = true;\n script.addEventListener(\"load\", ready, { once: true });\n script.addEventListener(\n \"error\",\n () => {\n // Drop the memo so a later attempt can retry a transient CDN failure\n // instead of being stuck with a permanently rejected promise.\n loaders.delete(siteKey);\n reject(new Error(\"Failed to load reCAPTCHA v3\"));\n },\n { once: true },\n );\n document.head.appendChild(script);\n });\n\n loaders.set(siteKey, promise);\n return promise;\n}\n\n/**\n * Mint a single-use reCAPTCHA v3 token, loading the script on first use.\n *\n * Tokens are valid for ~2 minutes and are consumed by one verification, so\n * this must be called per submission — never cached.\n */\nexport async function getRecaptchaV3Token(\n siteKey: string,\n action: string = EXPLORE_RECAPTCHA_ACTION,\n): Promise<string> {\n const api = await loadRecaptchaV3(siteKey);\n return api.execute(siteKey, { action });\n}\n","/**\n * Which routes a host site should render WITHOUT its marketing footer.\n *\n * ## Why this lives in the SDK\n *\n * It was written 39 times. The footer belongs to each website, so the obvious\n * place for the rule is each website — and eight agents asked to add it produced\n * five different helpers (`footerless-routes.ts`, `footer-visibility.ts`,\n * `footer-routes.ts`, `explore-route.ts`, plus six copies inlined straight into\n * a layout). All five behave the same today. The next person to change the rule\n * has to find all five, and will not.\n *\n * The rule is really a property of the SDK's own surfaces: `/explore` is\n * footerless because `ExplorePage` sizes itself to one viewport, which is a fact\n * about this package, not about any site. So it is declared here, once, and the\n * sites ask.\n *\n * ## Why `/explore` and nothing else\n *\n * `ExplorePage` renders a full-viewport chat pane (`100dvh` minus the site\n * chrome above it), so anything after it is below the fold by construction. On a\n * phone these footers are 1100-1330px — roughly 70% of the document — and that\n * bulk is what allowed a restored scroll offset to open the page on nothing but\n * footer, with zero pixels of the chat on screen.\n *\n * The SDK's OTHER pages are the opposite case and must keep their footer:\n * `/concepts`, `/concept/:slug`, `/answers` and `/answer/:slug` are ordinary\n * indexed content pages whose footer carries the site's internal links.\n * Stripping it would cost hundreds of pages their internal linking, and nobody\n * would notice for weeks.\n */\n\n/** Routes that render without the site footer. Exact paths, not prefixes. */\nexport const FOOTERLESS_ROUTES: readonly string[] = [\"/explore\"];\n\n/**\n * Normalise a router pathname for exact comparison.\n *\n * React Router matches case-insensitively (`caseSensitive` defaults to false)\n * and tolerates a trailing slash, so `/Explore/` reaches the same route element\n * as `/explore` and has to be treated the same way here. Half the fleet's\n * hand-written versions compared case-sensitively and would have rendered a\n * footer under the chat pane on `/Explore`.\n */\nexport function normalizeRoutePath(pathname: string): string {\n const trimmed = pathname.toLowerCase().replace(/\\/+$/, \"\");\n return trimmed === \"\" ? \"/\" : trimmed;\n}\n\n/**\n * True when this pathname is one of the SDK's full-screen surfaces.\n *\n * Deliberately an exact-set test. `startsWith(\"/explore\")` would also swallow a\n * future `/explore-beta`, and `includes(\"explore\")` would swallow far more —\n * including `/concept/explore-your-data`, which is a real shape of concept slug.\n */\nexport function isFooterlessPath(pathname: string): boolean {\n return FOOTERLESS_ROUTES.includes(normalizeRoutePath(pathname));\n}\n\n/** Inverse of {@link isFooterlessPath}, for layouts that read better positively. */\nexport function shouldRenderSiteFooter(pathname: string): boolean {\n return !isFooterlessPath(pathname);\n}\n"]}
@@ -72,6 +72,19 @@ async function getRecaptchaV3Token(siteKey, action = EXPLORE_RECAPTCHA_ACTION) {
72
72
  return api.execute(siteKey, { action });
73
73
  }
74
74
 
75
- export { EXPLORE_RECAPTCHA_ACTION, cn, getRecaptchaV3Token, loadRecaptchaV3 };
75
+ // src/utils/site-chrome.ts
76
+ var FOOTERLESS_ROUTES = ["/explore"];
77
+ function normalizeRoutePath(pathname) {
78
+ const trimmed = pathname.toLowerCase().replace(/\/+$/, "");
79
+ return trimmed === "" ? "/" : trimmed;
80
+ }
81
+ function isFooterlessPath(pathname) {
82
+ return FOOTERLESS_ROUTES.includes(normalizeRoutePath(pathname));
83
+ }
84
+ function shouldRenderSiteFooter(pathname) {
85
+ return !isFooterlessPath(pathname);
86
+ }
87
+
88
+ export { EXPLORE_RECAPTCHA_ACTION, FOOTERLESS_ROUTES, cn, getRecaptchaV3Token, isFooterlessPath, loadRecaptchaV3, normalizeRoutePath, shouldRenderSiteFooter };
76
89
  //# sourceMappingURL=index.mjs.map
77
90
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/cn.ts","../../src/utils/recaptcha-v3.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * Utility function to merge Tailwind CSS classes\n * Combines clsx and tailwind-merge for optimal class handling\n */\n\nimport { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Merge multiple class names intelligently\n * - Handles conditional classes via clsx\n * - Deduplicates and resolves Tailwind conflicts via tailwind-merge\n *\n * @example\n * ```ts\n * cn('px-4 py-2', 'bg-blue-500', { 'text-white': isActive })\n * // => 'px-4 py-2 bg-blue-500 text-white' (if isActive is true)\n * ```\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","/**\n * reCAPTCHA v3 (invisible, score-based) loader and token minter.\n *\n * Deliberately dependency-free: the SDK already ships the `react-google-recaptcha`\n * peer for the **v2 checkbox** used by `ContactPage`, and the two widgets cannot\n * share a script tag — v3 needs `?render=<siteKey>` on the URL, v2 must not have\n * it. This module injects its own v3 script and leaves the v2 integration alone.\n *\n * The Explore chat turns v3 on purely from the backend catalog\n * (`exploreCatalog.captchaRequired && recaptchaSiteKey`), so nothing is loaded\n * and no third-party request is made on sites where captcha is off.\n */\n\n/** The slice of the `grecaptcha` global this module uses. */\nexport interface RecaptchaV3 {\n /** Runs the callback once the API is fully initialised. */\n ready(callback: () => void): void;\n /** Mints a single-use token for `action`. */\n execute(siteKey: string, options: { action: string }): Promise<string>;\n}\n\ndeclare global {\n interface Window {\n grecaptcha?: RecaptchaV3;\n }\n}\n\nconst SCRIPT_ID = \"boff-recaptcha-v3\";\nconst SCRIPT_SRC = \"https://www.google.com/recaptcha/api.js?render=\";\n\n/** Default action name reported to reCAPTCHA for Explore chat submissions. */\nexport const EXPLORE_RECAPTCHA_ACTION = \"explore_chat\";\n\n/**\n * One in-flight/settled promise per site key. Guarantees the script tag is\n * injected at most once even when several components mount at the same time,\n * and lets later callers await the same load instead of racing it.\n */\nconst loaders = new Map<string, Promise<RecaptchaV3>>();\n\nfunction isBrowser(): boolean {\n return typeof window !== \"undefined\" && typeof document !== \"undefined\";\n}\n\n/**\n * Load the reCAPTCHA v3 script for `siteKey` and resolve once `grecaptcha` is\n * ready to mint tokens.\n *\n * - Injects the script at most once per site key (subsequent calls reuse the\n * same promise).\n * - Adopts an already-present script tag (e.g. injected by the host site)\n * rather than adding a second one.\n * - Rejects rather than hanging when the script fails to load, so the caller\n * can decide whether to submit without a token or surface an error.\n */\nexport function loadRecaptchaV3(siteKey: string): Promise<RecaptchaV3> {\n if (!siteKey) {\n return Promise.reject(new Error(\"reCAPTCHA site key is required\"));\n }\n if (!isBrowser()) {\n return Promise.reject(\n new Error(\"reCAPTCHA v3 can only be loaded in a browser\"),\n );\n }\n\n const existing = loaders.get(siteKey);\n if (existing) return existing;\n\n const promise = new Promise<RecaptchaV3>((resolve, reject) => {\n const ready = () => {\n const api = window.grecaptcha;\n if (!api) {\n reject(new Error(\"reCAPTCHA loaded but grecaptcha is unavailable\"));\n return;\n }\n // `ready` queues until the API has finished initialising internally;\n // calling `execute` before that throws.\n api.ready(() => resolve(api));\n };\n\n // Already loaded by this module, another SDK instance, or the host site.\n if (window.grecaptcha) {\n ready();\n return;\n }\n\n const scriptId = `${SCRIPT_ID}-${siteKey}`;\n const found = document.getElementById(scriptId);\n if (found) {\n found.addEventListener(\"load\", ready, { once: true });\n found.addEventListener(\n \"error\",\n () => reject(new Error(\"Failed to load reCAPTCHA v3\")),\n { once: true },\n );\n return;\n }\n\n const script = document.createElement(\"script\");\n script.id = scriptId;\n script.src = `${SCRIPT_SRC}${encodeURIComponent(siteKey)}`;\n script.async = true;\n script.defer = true;\n script.addEventListener(\"load\", ready, { once: true });\n script.addEventListener(\n \"error\",\n () => {\n // Drop the memo so a later attempt can retry a transient CDN failure\n // instead of being stuck with a permanently rejected promise.\n loaders.delete(siteKey);\n reject(new Error(\"Failed to load reCAPTCHA v3\"));\n },\n { once: true },\n );\n document.head.appendChild(script);\n });\n\n loaders.set(siteKey, promise);\n return promise;\n}\n\n/**\n * Mint a single-use reCAPTCHA v3 token, loading the script on first use.\n *\n * Tokens are valid for ~2 minutes and are consumed by one verification, so\n * this must be called per submission — never cached.\n */\nexport async function getRecaptchaV3Token(\n siteKey: string,\n action: string = EXPLORE_RECAPTCHA_ACTION,\n): Promise<string> {\n const api = await loadRecaptchaV3(siteKey);\n return api.execute(siteKey, { action });\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/cn.ts","../../src/utils/recaptcha-v3.ts","../../src/utils/site-chrome.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * Utility function to merge Tailwind CSS classes\n * Combines clsx and tailwind-merge for optimal class handling\n */\n\nimport { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * Merge multiple class names intelligently\n * - Handles conditional classes via clsx\n * - Deduplicates and resolves Tailwind conflicts via tailwind-merge\n *\n * @example\n * ```ts\n * cn('px-4 py-2', 'bg-blue-500', { 'text-white': isActive })\n * // => 'px-4 py-2 bg-blue-500 text-white' (if isActive is true)\n * ```\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","/**\n * reCAPTCHA v3 (invisible, score-based) loader and token minter.\n *\n * Deliberately dependency-free: the SDK already ships the `react-google-recaptcha`\n * peer for the **v2 checkbox** used by `ContactPage`, and the two widgets cannot\n * share a script tag — v3 needs `?render=<siteKey>` on the URL, v2 must not have\n * it. This module injects its own v3 script and leaves the v2 integration alone.\n *\n * The Explore chat turns v3 on purely from the backend catalog\n * (`exploreCatalog.captchaRequired && recaptchaSiteKey`), so nothing is loaded\n * and no third-party request is made on sites where captcha is off.\n */\n\n/** The slice of the `grecaptcha` global this module uses. */\nexport interface RecaptchaV3 {\n /** Runs the callback once the API is fully initialised. */\n ready(callback: () => void): void;\n /** Mints a single-use token for `action`. */\n execute(siteKey: string, options: { action: string }): Promise<string>;\n}\n\ndeclare global {\n interface Window {\n grecaptcha?: RecaptchaV3;\n }\n}\n\nconst SCRIPT_ID = \"boff-recaptcha-v3\";\nconst SCRIPT_SRC = \"https://www.google.com/recaptcha/api.js?render=\";\n\n/** Default action name reported to reCAPTCHA for Explore chat submissions. */\nexport const EXPLORE_RECAPTCHA_ACTION = \"explore_chat\";\n\n/**\n * One in-flight/settled promise per site key. Guarantees the script tag is\n * injected at most once even when several components mount at the same time,\n * and lets later callers await the same load instead of racing it.\n */\nconst loaders = new Map<string, Promise<RecaptchaV3>>();\n\nfunction isBrowser(): boolean {\n return typeof window !== \"undefined\" && typeof document !== \"undefined\";\n}\n\n/**\n * Load the reCAPTCHA v3 script for `siteKey` and resolve once `grecaptcha` is\n * ready to mint tokens.\n *\n * - Injects the script at most once per site key (subsequent calls reuse the\n * same promise).\n * - Adopts an already-present script tag (e.g. injected by the host site)\n * rather than adding a second one.\n * - Rejects rather than hanging when the script fails to load, so the caller\n * can decide whether to submit without a token or surface an error.\n */\nexport function loadRecaptchaV3(siteKey: string): Promise<RecaptchaV3> {\n if (!siteKey) {\n return Promise.reject(new Error(\"reCAPTCHA site key is required\"));\n }\n if (!isBrowser()) {\n return Promise.reject(\n new Error(\"reCAPTCHA v3 can only be loaded in a browser\"),\n );\n }\n\n const existing = loaders.get(siteKey);\n if (existing) return existing;\n\n const promise = new Promise<RecaptchaV3>((resolve, reject) => {\n const ready = () => {\n const api = window.grecaptcha;\n if (!api) {\n reject(new Error(\"reCAPTCHA loaded but grecaptcha is unavailable\"));\n return;\n }\n // `ready` queues until the API has finished initialising internally;\n // calling `execute` before that throws.\n api.ready(() => resolve(api));\n };\n\n // Already loaded by this module, another SDK instance, or the host site.\n if (window.grecaptcha) {\n ready();\n return;\n }\n\n const scriptId = `${SCRIPT_ID}-${siteKey}`;\n const found = document.getElementById(scriptId);\n if (found) {\n found.addEventListener(\"load\", ready, { once: true });\n found.addEventListener(\n \"error\",\n () => reject(new Error(\"Failed to load reCAPTCHA v3\")),\n { once: true },\n );\n return;\n }\n\n const script = document.createElement(\"script\");\n script.id = scriptId;\n script.src = `${SCRIPT_SRC}${encodeURIComponent(siteKey)}`;\n script.async = true;\n script.defer = true;\n script.addEventListener(\"load\", ready, { once: true });\n script.addEventListener(\n \"error\",\n () => {\n // Drop the memo so a later attempt can retry a transient CDN failure\n // instead of being stuck with a permanently rejected promise.\n loaders.delete(siteKey);\n reject(new Error(\"Failed to load reCAPTCHA v3\"));\n },\n { once: true },\n );\n document.head.appendChild(script);\n });\n\n loaders.set(siteKey, promise);\n return promise;\n}\n\n/**\n * Mint a single-use reCAPTCHA v3 token, loading the script on first use.\n *\n * Tokens are valid for ~2 minutes and are consumed by one verification, so\n * this must be called per submission — never cached.\n */\nexport async function getRecaptchaV3Token(\n siteKey: string,\n action: string = EXPLORE_RECAPTCHA_ACTION,\n): Promise<string> {\n const api = await loadRecaptchaV3(siteKey);\n return api.execute(siteKey, { action });\n}\n","/**\n * Which routes a host site should render WITHOUT its marketing footer.\n *\n * ## Why this lives in the SDK\n *\n * It was written 39 times. The footer belongs to each website, so the obvious\n * place for the rule is each website — and eight agents asked to add it produced\n * five different helpers (`footerless-routes.ts`, `footer-visibility.ts`,\n * `footer-routes.ts`, `explore-route.ts`, plus six copies inlined straight into\n * a layout). All five behave the same today. The next person to change the rule\n * has to find all five, and will not.\n *\n * The rule is really a property of the SDK's own surfaces: `/explore` is\n * footerless because `ExplorePage` sizes itself to one viewport, which is a fact\n * about this package, not about any site. So it is declared here, once, and the\n * sites ask.\n *\n * ## Why `/explore` and nothing else\n *\n * `ExplorePage` renders a full-viewport chat pane (`100dvh` minus the site\n * chrome above it), so anything after it is below the fold by construction. On a\n * phone these footers are 1100-1330px — roughly 70% of the document — and that\n * bulk is what allowed a restored scroll offset to open the page on nothing but\n * footer, with zero pixels of the chat on screen.\n *\n * The SDK's OTHER pages are the opposite case and must keep their footer:\n * `/concepts`, `/concept/:slug`, `/answers` and `/answer/:slug` are ordinary\n * indexed content pages whose footer carries the site's internal links.\n * Stripping it would cost hundreds of pages their internal linking, and nobody\n * would notice for weeks.\n */\n\n/** Routes that render without the site footer. Exact paths, not prefixes. */\nexport const FOOTERLESS_ROUTES: readonly string[] = [\"/explore\"];\n\n/**\n * Normalise a router pathname for exact comparison.\n *\n * React Router matches case-insensitively (`caseSensitive` defaults to false)\n * and tolerates a trailing slash, so `/Explore/` reaches the same route element\n * as `/explore` and has to be treated the same way here. Half the fleet's\n * hand-written versions compared case-sensitively and would have rendered a\n * footer under the chat pane on `/Explore`.\n */\nexport function normalizeRoutePath(pathname: string): string {\n const trimmed = pathname.toLowerCase().replace(/\\/+$/, \"\");\n return trimmed === \"\" ? \"/\" : trimmed;\n}\n\n/**\n * True when this pathname is one of the SDK's full-screen surfaces.\n *\n * Deliberately an exact-set test. `startsWith(\"/explore\")` would also swallow a\n * future `/explore-beta`, and `includes(\"explore\")` would swallow far more —\n * including `/concept/explore-your-data`, which is a real shape of concept slug.\n */\nexport function isFooterlessPath(pathname: string): boolean {\n return FOOTERLESS_ROUTES.includes(normalizeRoutePath(pathname));\n}\n\n/** Inverse of {@link isFooterlessPath}, for layouts that read better positively. */\nexport function shouldRenderSiteFooter(pathname: string): boolean {\n return !isFooterlessPath(pathname);\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/website-sdk",
3
- "version": "2026.922.5",
3
+ "version": "2026.923.2",
4
4
  "description": "Shared SDK for Burdenoff product websites - reusable React components, utilities, and configurations",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -74,8 +74,9 @@
74
74
  "type-check": "bash scripts/build-limits.sh tsc --noEmit",
75
75
  "prepublishOnly": "bun run build",
76
76
  "test": "vitest",
77
+ "test:run": "vitest run --maxWorkers=4",
77
78
  "test:coverage": "vitest --coverage",
78
- "sanity": "bash scripts/build-limits.sh --gate bash -c 'bun run format:check && bun run lint:sanity && bun run type-check && bun run build'"
79
+ "sanity": "bash scripts/build-limits.sh --gate bash -c 'bun run format:check && bun run lint:sanity && bun run type-check && bun run test:run && bun run build'"
79
80
  },
80
81
  "keywords": [
81
82
  "burdenoff",