@leed-ai/cli 0.0.0 → 6.19.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/LICENSE.md +57 -0
- package/README.md +120 -3
- package/dist/LICENSE.md +57 -0
- package/dist/acorn-DAgDT1om.js +5087 -0
- package/dist/angular-l0_4PK8N.js +3598 -0
- package/dist/authShared-Dray38Ls.js +1161 -0
- package/dist/babel-CJeRQd5u.js +10038 -0
- package/dist/buildResults-tiq1nugP.js +21180 -0
- package/dist/chunk-1VJ9lAw7.js +42 -0
- package/dist/cliAuth-38JjD6hw.js +15 -0
- package/dist/env-B0bHiXJJ.js +29 -0
- package/dist/errors-CqKamGCu.js +481 -0
- package/dist/estree-E8WG8UHj.js +6930 -0
- package/dist/flow-6xaRIbPQ.js +52255 -0
- package/dist/glimmer-aJPjsfbT.js +7272 -0
- package/dist/graphql-mcgtPEiZ.js +1901 -0
- package/dist/html--eeea3Kl.js +6553 -0
- package/dist/leed.eleventy.mjs +6525 -0
- package/dist/leed.mjs +88952 -0
- package/dist/leed.sources.json +798 -0
- package/dist/leedLogger-Zkada5aO.js +78 -0
- package/dist/markdown-D73jNKwx.js +6836 -0
- package/dist/meriyah-CMp1nDDW.js +8932 -0
- package/dist/package.json +101 -0
- package/dist/postcss-h4fBr2Fn.js +6760 -0
- package/dist/prettier-CwFSKxfN.js +16821 -0
- package/dist/static/customer-site-worker/package.json +9 -0
- package/dist/static/customer-site-worker/src/assetsHydration.ts +55 -0
- package/dist/static/customer-site-worker/src/constants.ts +20 -0
- package/dist/static/customer-site-worker/src/cors.ts +120 -0
- package/dist/static/customer-site-worker/src/index.ts +242 -0
- package/dist/static/customer-site-worker/src/mcp-proxy.ts +98 -0
- package/dist/static/customer-site-worker/src/proxy.ts +92 -0
- package/dist/static/customer-site-worker/src/proxyHeaders.ts +47 -0
- package/dist/static/customer-site-worker/src/tier.ts +40 -0
- package/dist/static/customer-site-worker/src/types.ts +82 -0
- package/dist/static/customer-site-worker/test/functions-mock.worker.js +50 -0
- package/dist/static/customer-site-worker/tsconfig.json +19 -0
- package/dist/static/customer-site-worker/vitest.workers.config.ts +61 -0
- package/dist/static/install/husky/colors.mjs +5 -0
- package/dist/static/install/husky/hooks/commit-msg +1 -0
- package/dist/static/install/husky/hooks/pre-commit +1 -0
- package/dist/static/install/husky/hooks/pre-push +1 -0
- package/dist/static/install/husky/valid-command.mjs +29 -0
- package/dist/static/install/husky/valid-commit-msg.mjs +20 -0
- package/dist/static/install/package.json +10 -0
- package/dist/static/install/packages.txt +4 -0
- package/dist/static/install/tasks.json +111 -0
- package/dist/static/javascript/detectIncognito.js +195 -0
- package/dist/static/javascript/documentation.js +2781 -0
- package/dist/static/javascript/fp/fp.esm.js +2896 -0
- package/dist/static/javascript/fp/fp.js +3067 -0
- package/dist/static/javascript/live-search.js +986 -0
- package/dist/static/javascript/lunr-search.js +843 -0
- package/dist/static/javascript/timeme.js +451 -0
- package/dist/static/javascript/tracker/webmcp.js +67 -0
- package/dist/static/javascript/tracker/whisper.js +1 -0
- package/dist/static/javascript/utilities.js +819 -0
- package/dist/static/javascript/versions.json +74 -0
- package/dist/static/javascript/zoomable.js +488 -0
- package/dist/static/site/_data/eleventyComputed.js +228 -0
- package/dist/static/site/_data/leed-form-data.json +253 -0
- package/dist/static/site/_includes/leed/api/bits/array-type.hbs +24 -0
- package/dist/static/site/_includes/leed/api/bits/badges.hbs +9 -0
- package/dist/static/site/_includes/leed/api/bits/description.hbs +9 -0
- package/dist/static/site/_includes/leed/api/bits/examples.hbs +42 -0
- package/dist/static/site/_includes/leed/api/bits/scopes.hbs +28 -0
- package/dist/static/site/_includes/leed/api/object.hbs +110 -0
- package/dist/static/site/_includes/leed/api/parameters.hbs +11 -0
- package/dist/static/site/_includes/leed/api/property.hbs +153 -0
- package/dist/static/site/_includes/leed/api/request-body.hbs +51 -0
- package/dist/static/site/_includes/leed/api/responses.hbs +89 -0
- package/dist/static/site/_includes/leed/api/security-constraints.hbs +59 -0
- package/dist/static/site/_includes/leed/api/security.hbs +75 -0
- package/dist/static/site/_includes/leed/components/pagination.hbs +45 -0
- package/dist/static/site/_includes/leed/components/scroll-status-indicator-horizontal.hbs +22 -0
- package/dist/static/site/_includes/leed/docs/alpha/api.hbs +6 -0
- package/dist/static/site/_includes/leed/docs/alpha/base.hbs +26 -0
- package/dist/static/site/_includes/leed/docs/alpha/documentation.hbs +7 -0
- package/dist/static/site/_includes/leed/docs/api-article.hbs +75 -0
- package/dist/static/site/_includes/leed/docs/bravo/api.hbs +5 -0
- package/dist/static/site/_includes/leed/docs/bravo/base.hbs +31 -0
- package/dist/static/site/_includes/leed/docs/bravo/documentation.hbs +7 -0
- package/dist/static/site/_includes/leed/docs/charlie/api.hbs +6 -0
- package/dist/static/site/_includes/leed/docs/charlie/base.hbs +27 -0
- package/dist/static/site/_includes/leed/docs/charlie/documentation.hbs +10 -0
- package/dist/static/site/_includes/leed/docs/doc-article.hbs +40 -0
- package/dist/static/site/_includes/leed/docs/docs-footer.hbs +35 -0
- package/dist/static/site/_includes/leed/docs/docs-header.hbs +59 -0
- package/dist/static/site/_includes/leed/docs/document-search-modal.hbs +36 -0
- package/dist/static/site/_includes/leed/docs/document-search.hbs +23 -0
- package/dist/static/site/_includes/leed/docs/footer.hbs +12 -0
- package/dist/static/site/_includes/leed/docs/header/brand.hbs +19 -0
- package/dist/static/site/_includes/leed/docs/header/chrome.hbs +33 -0
- package/dist/static/site/_includes/leed/docs/header/compact.hbs +19 -0
- package/dist/static/site/_includes/leed/docs/header/cta.hbs +15 -0
- package/dist/static/site/_includes/leed/docs/header/nav.hbs +18 -0
- package/dist/static/site/_includes/leed/docs/header/top-search.hbs +15 -0
- package/dist/static/site/_includes/leed/docs/mini-bar.hbs +53 -0
- package/dist/static/site/_includes/leed/docs/pagination.hbs +19 -0
- package/dist/static/site/_includes/leed/docs/shell.hbs +56 -0
- package/dist/static/site/_includes/leed/docs/sidebar.hbs +52 -0
- package/dist/static/site/_includes/leed/docs/social-icons.hbs +81 -0
- package/dist/static/site/_includes/leed/docs/style.hbs +17 -0
- package/dist/static/site/_includes/leed/docs/toc.hbs +7 -0
- package/dist/static/site/_includes/leed/form.hbs +266 -0
- package/dist/static/site/_includes/leed/head.hbs +67 -0
- package/dist/static/site/_includes/leed/menu/builder.hbs +35 -0
- package/dist/static/site/_includes/leed/menu/item.hbs +67 -0
- package/dist/static/site/_includes/leed/menu/link.hbs +59 -0
- package/dist/static/site/_includes/leed/metadata/ogCard.hbs +40 -0
- package/dist/static/site/_includes/leed/metadata/siteMetadata.hbs +24 -0
- package/dist/static/site/_includes/leed/metadata/twitterCard.hbs +34 -0
- package/dist/static/site/_includes/leed/popup-video.hbs +21 -0
- package/dist/static/site/_includes/leed/stub-template.hbs +17 -0
- package/dist/static/site/_includes/leed/unsubscribe-form.hbs +30 -0
- package/dist/static/site/_layouts/leed-documentation.hbs +12 -0
- package/dist/static/site/leed-feed-atom.hbs +42 -0
- package/dist/static/site/leed-feed-json.hbs +56 -0
- package/dist/static/site/leed-feed-rss.hbs +39 -0
- package/dist/static/site/leed-headers.hbs +38 -0
- package/dist/static/site/leed-redirects.hbs +10 -0
- package/dist/static/site/leed-sitemap-pageType.hbs +28 -0
- package/dist/static/site/leed-sitemap.hbs +19 -0
- package/dist/static/site/leed-unsubscribe.hbs +44 -0
- package/dist/static/site/leed-unsubscribed.hbs +40 -0
- package/dist/static/tailwind/components/alerts.css +132 -0
- package/dist/static/tailwind/components/api-methods.css +39 -0
- package/dist/static/tailwind/components/breadcrumbs.css +31 -0
- package/dist/static/tailwind/components/code.css +88 -0
- package/dist/static/tailwind/components/collapsible.css +38 -0
- package/dist/static/tailwind/components/document-search.css +222 -0
- package/dist/static/tailwind/components/form.css +185 -0
- package/dist/static/tailwind/components/kbd.css +7 -0
- package/dist/static/tailwind/components/pagination.css +81 -0
- package/dist/static/tailwind/components/scroll-status-indicator.css +5 -0
- package/dist/static/tailwind/components/tab-groups.css +117 -0
- package/dist/static/tailwind/components/tables.css +0 -0
- package/dist/static/tailwind/components/toc.css +60 -0
- package/dist/static/tailwind/components.css +248 -0
- package/dist/static/tailwind/defaults.css +260 -0
- package/dist/static/tailwind/docs/api.css +701 -0
- package/dist/static/tailwind/docs/components/blockquote.css +5 -0
- package/dist/static/tailwind/docs/components/button.css +11 -0
- package/dist/static/tailwind/docs/components/docs-pagination.css +71 -0
- package/dist/static/tailwind/docs/components/mobile-bar.css +83 -0
- package/dist/static/tailwind/docs/components/navigation.css +279 -0
- package/dist/static/tailwind/docs/components/table.css +54 -0
- package/dist/static/tailwind/docs/components.css +6 -0
- package/dist/static/tailwind/docs/docs.css +427 -0
- package/dist/static/tailwind/docs/layouts/alpha.css +108 -0
- package/dist/static/tailwind/docs/layouts/bravo.css +218 -0
- package/dist/static/tailwind/docs/layouts/charlie.css +105 -0
- package/dist/static/tailwind/docs/layouts.css +3 -0
- package/dist/static/tailwind/docs/themes/amber.css +113 -0
- package/dist/static/tailwind/docs/themes/blue.css +94 -0
- package/dist/static/tailwind/docs/themes/cyan.css +111 -0
- package/dist/static/tailwind/docs/themes/emerald.css +111 -0
- package/dist/static/tailwind/docs/themes/fuchsia.css +111 -0
- package/dist/static/tailwind/docs/themes/green.css +95 -0
- package/dist/static/tailwind/docs/themes/indigo.css +111 -0
- package/dist/static/tailwind/docs/themes/lime.css +111 -0
- package/dist/static/tailwind/docs/themes/orange.css +111 -0
- package/dist/static/tailwind/docs/themes/pink.css +111 -0
- package/dist/static/tailwind/docs/themes/purple.css +111 -0
- package/dist/static/tailwind/docs/themes/red.css +111 -0
- package/dist/static/tailwind/docs/themes/rose.css +111 -0
- package/dist/static/tailwind/docs/themes/sky.css +111 -0
- package/dist/static/tailwind/docs/themes/teal.css +111 -0
- package/dist/static/tailwind/docs/themes/violet.css +111 -0
- package/dist/static/tailwind/docs/themes/yellow.css +111 -0
- package/dist/static/tailwind/docs/themes.css +84 -0
- package/dist/static/tailwind/font-awesome/free-7.3.1/brands.css +2455 -0
- package/dist/static/tailwind/font-awesome/free-7.3.1/fontawesome.css +8997 -0
- package/dist/static/tailwind/font-awesome/free-7.3.1/fonts.css +45 -0
- package/dist/static/tailwind/font-awesome/free-7.3.1/icons.json +2608 -0
- package/dist/static/tailwind/font-awesome/free-7.3.1.css +3 -0
- package/dist/static/tailwind/font-awesome/pro-7.0.0/brands.css +2215 -0
- package/dist/static/tailwind/font-awesome/pro-7.0.0/fontawesome.css +19281 -0
- package/dist/static/tailwind/font-awesome/pro-7.0.0/fonts.css +445 -0
- package/dist/static/tailwind/font-awesome/pro-7.0.0.css +3 -0
- package/dist/static/tailwind/font-awesome/pro-7.3.1/brands.css +2455 -0
- package/dist/static/tailwind/font-awesome/pro-7.3.1/fontawesome.css +20365 -0
- package/dist/static/tailwind/font-awesome/pro-7.3.1/fonts.css +589 -0
- package/dist/static/tailwind/font-awesome/pro-7.3.1.css +3 -0
- package/dist/static/tailwind/fonts/all.css +6 -0
- package/dist/static/tailwind/fonts/config.jsonc +31 -0
- package/dist/static/tailwind/fonts/geist-mono.css +142 -0
- package/dist/static/tailwind/fonts/geist.css +142 -0
- package/dist/static/tailwind/fonts/inter.css +215 -0
- package/dist/static/tailwind/fonts/jetbrains-mono.css +126 -0
- package/dist/static/tailwind/fonts/noto-sans.css +134 -0
- package/dist/static/tailwind/fonts/open-sans.css +89 -0
- package/dist/static/tailwind/highlighters.css +14 -0
- package/dist/static/tailwind/hljs/README.md +53 -0
- package/dist/static/tailwind/hljs/a11y.css +96 -0
- package/dist/static/tailwind/hljs/atom-fruit.css +90 -0
- package/dist/static/tailwind/hljs/atom-one.css +164 -0
- package/dist/static/tailwind/hljs/default.css +164 -0
- package/dist/static/tailwind/hljs/github.css +101 -0
- package/dist/static/tailwind/hljs/google-code.css +112 -0
- package/dist/static/tailwind/hljs/grayscale.css +115 -0
- package/dist/static/tailwind/hljs/gruvbox-hard.css +115 -0
- package/dist/static/tailwind/hljs/gruvbox-medium.css +115 -0
- package/dist/static/tailwind/hljs/gruvbox-soft.css +115 -0
- package/dist/static/tailwind/hljs/papercolor.css +120 -0
- package/dist/static/tailwind/hljs/qt-creator.css +91 -0
- package/dist/static/tailwind/hljs/summerfruit.css +115 -0
- package/dist/static/tailwind/hljs/tomorrow.css +115 -0
- package/dist/static/tailwind/leed.css +170 -0
- package/dist/static/tailwind/site-tokens.reference.css +105 -0
- package/dist/static/tailwind/utils/icons.css +102 -0
- package/dist/static/tailwind/utils/utilities.css +105 -0
- package/dist/static/templates/pages/PAGE.hbs +27 -0
- package/dist/static/templates/pages/PAGE_LAYOUT.hbs +19 -0
- package/dist/static/templates/pages/PAGE_TYPE_INDEX.hbs +89 -0
- package/dist/static/templates/pages/PAGINATED_PAGE_TYPE_ITEM.hbs +52 -0
- package/dist/static/templates/pages/PAGINATED_PAGE_TYPE_LIST.hbs +39 -0
- package/dist/static/templates/pages/PAGINATED_PAGE_TYPE_PAGINATION.hbs +11 -0
- package/dist/static/templates/raw-content/.ci/build.sh +58 -0
- package/dist/static/templates/raw-content/README.md +179 -0
- package/dist/static/templates/raw-content/_gitignore +50 -0
- package/dist/static/templates/raw-content/src/404.hbs +21 -0
- package/dist/static/templates/raw-content/src/_data/autolink.json +1 -0
- package/dist/static/templates/raw-content/src/_data/ctaList.json +1 -0
- package/dist/static/templates/raw-content/src/_data/leedForms.json +1 -0
- package/dist/static/templates/raw-content/src/_data/menu.json +1 -0
- package/dist/static/templates/raw-content/src/_includes/footer.hbs +18 -0
- package/dist/static/templates/raw-content/src/_includes/header-includes.hbs +25 -0
- package/dist/static/templates/raw-content/src/_includes/header.hbs +5 -0
- package/dist/static/templates/raw-content/src/_includes/site-template.hbs +23 -0
- package/dist/static/templates/raw-content/src/_includes/unsubscribe-DISABLED.hbs +17 -0
- package/dist/static/templates/raw-content/src/_includes/unsubscribed-DISABLED.hbs +13 -0
- package/dist/static/templates/raw-content/src/_layouts/empty.hbs +2 -0
- package/dist/static/templates/raw-content/src/index.hbs +21 -0
- package/dist/static/templates/raw-content/src/robots.hbs +9 -0
- package/dist/static/templates/raw-content/tailwind/site/base.css +44 -0
- package/dist/static/templates/raw-content/tailwind/site.config.css +3 -0
- package/dist/static/templates/sandbox/_gitignore +50 -0
- package/dist/static/templates/sandbox/identity.json +1 -0
- package/dist/static/templates/sandbox/src/404.hbs +21 -0
- package/dist/static/templates/sandbox/src/_data/autolink.json +1 -0
- package/dist/static/templates/sandbox/src/_data/ctaList.json +1 -0
- package/dist/static/templates/sandbox/src/_data/labelList.json +9 -0
- package/dist/static/templates/sandbox/src/_data/leedForms.json +20 -0
- package/dist/static/templates/sandbox/src/_data/menu.json +19 -0
- package/dist/static/templates/sandbox/src/_data/pageTypeList.json +14 -0
- package/dist/static/templates/sandbox/src/_data/searchIndexList.json +1 -0
- package/dist/static/templates/sandbox/src/_data/userList.json +11 -0
- package/dist/static/templates/sandbox/src/_includes/footer.hbs +18 -0
- package/dist/static/templates/sandbox/src/_includes/header-includes.hbs +25 -0
- package/dist/static/templates/sandbox/src/_includes/header.hbs +5 -0
- package/dist/static/templates/sandbox/src/_includes/site-template.hbs +23 -0
- package/dist/static/templates/sandbox/src/_layouts/empty.hbs +2 -0
- package/dist/static/templates/sandbox/src/index.hbs +20 -0
- package/dist/static/templates/sandbox/src/robots.hbs +9 -0
- package/dist/static/templates/sandbox/src/src.11tydata.json +39 -0
- package/dist/static/templates/sandbox/tailwind/site/base.css +44 -0
- package/dist/static/templates/sandbox/tailwind/site.config.css +3 -0
- package/dist/thrown-sukVkp_1.js +113 -0
- package/dist/typescript-D4T0V_Hn.js +23004 -0
- package/dist/yaml-9wR8GxF_.js +5236 -0
- package/package.json +152 -10
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live documentation search (#329) — the Starter+ replacement for lunr-search.js.
|
|
3
|
+
*
|
|
4
|
+
* Instead of downloading a prebuilt Lunr index, this queries `/api/search` on the
|
|
5
|
+
* site's own origin (same-origin, no CORS). Typing runs cheap `mode=freetext`
|
|
6
|
+
* requests; pressing Enter runs one `mode=hybrid` request, the only path that
|
|
7
|
+
* spends a semantic embedding.
|
|
8
|
+
*
|
|
9
|
+
* Shipped as a plain classic script (no bundler), so it is one self-contained
|
|
10
|
+
* file like the lunr client it replaces. Its whole UI layer — the modal
|
|
11
|
+
* anchoring, Alpine `headerSearchShow` contract, capture-phase Ctrl+K handler,
|
|
12
|
+
* arrow-key selection and localStorage recent searches — is preserved verbatim
|
|
13
|
+
* from that file so both header variants and any custom `docs-header.hbs` keep
|
|
14
|
+
* working. Only the data layer changed.
|
|
15
|
+
*
|
|
16
|
+
* The page emits only `pt` (this page's page type id). The SEARCH INDEX is a
|
|
17
|
+
* build-time concern of the prebuilt Lunr index and nothing else — live search
|
|
18
|
+
* never sees it. The searchable universe is resolved server-side from `pt`, and
|
|
19
|
+
* the filter chips come from `/api/search/facets`, browser-cached, so an index
|
|
20
|
+
* edited in the CMS is picked up without a republish.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SEARCH_ENDPOINT = "/api/search";
|
|
24
|
+
/** Filter chips for the modal. Cache-Control'd by the origin; fetched once per open. */
|
|
25
|
+
const FACETS_ENDPOINT = "/api/search/facets";
|
|
26
|
+
/** Below this many characters, typing shows recent searches instead of querying. */
|
|
27
|
+
const MIN_QUERY_LENGTH = 3;
|
|
28
|
+
/** Debounce for the freetext-while-typing path. */
|
|
29
|
+
const SEARCH_DEBOUNCE_MS = 300;
|
|
30
|
+
|
|
31
|
+
/** Page types of the reader's search index, from `/api/search/facets`. */
|
|
32
|
+
let searchFacets = [];
|
|
33
|
+
/** Set once the facets request has resolved, so the modal fetches them only once. */
|
|
34
|
+
let facetsLoaded = false;
|
|
35
|
+
/**
|
|
36
|
+
* Set when the server says this page type has no usable search index. That is a
|
|
37
|
+
* PERMANENT configuration state, not an outage, so the modal stops querying
|
|
38
|
+
* instead of showing a "temporarily unavailable" box on every keystroke.
|
|
39
|
+
*/
|
|
40
|
+
let searchUnconfigured = false;
|
|
41
|
+
/** The active filter chip's page type id, or null for the whole index. */
|
|
42
|
+
let activeFilter = null;
|
|
43
|
+
/**
|
|
44
|
+
* The page type of the page being read, emitted by `head.hbs`. This is the ONLY
|
|
45
|
+
* search-related value the page carries — everything else is resolved by the
|
|
46
|
+
* server. Read defensively once: a bare reference would throw a ReferenceError
|
|
47
|
+
* and kill search outright if the constant were ever absent.
|
|
48
|
+
*/
|
|
49
|
+
const currentPageTypeId = typeof pt === "string" ? pt : "";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Confirm the page can host live search. Mirrors `initializeSearch`'s guard
|
|
53
|
+
* order in lunr-search.js: the MODAL is the anchor, not the trigger button — the
|
|
54
|
+
* docs shell always renders the modal, while the button lives in the header,
|
|
55
|
+
* which a site can replace via `_includes/docs-header.hbs`.
|
|
56
|
+
*/
|
|
57
|
+
function initializeSearch() {
|
|
58
|
+
// Silent: a non-docs page legitimately has no modal, and that is not an error.
|
|
59
|
+
//
|
|
60
|
+
// No console.log/debug/info anywhere in this file. The rolldown log-stripping
|
|
61
|
+
// (`manualPureFunctions`, CI-only) covers ONLY the bundled trackers built from
|
|
62
|
+
// src/trackers/*.ts; a hand-written script under static/javascript is copied
|
|
63
|
+
// verbatim to the customer site (rollup-plugin-copy, then fs.copyFile), and the
|
|
64
|
+
// `.min.js` in the versioned filename is naming, not minification. So anything
|
|
65
|
+
// logged here prints in every reader's console. console.warn/error stay — the
|
|
66
|
+
// tracker configs deliberately leave those two in production too.
|
|
67
|
+
//
|
|
68
|
+
// Availability is NOT decided here. The server is the only authority on whether
|
|
69
|
+
// this page type has a usable search index, and it answers on the first request
|
|
70
|
+
// — so a site whose index is configured (or removed) takes effect immediately,
|
|
71
|
+
// with no republish and nothing baked into the page to go stale.
|
|
72
|
+
return !!document.querySelector("#document-search-modal");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Load the filter chips for this page's search index, once per page view.
|
|
77
|
+
*
|
|
78
|
+
* Best-effort by design: chips are an affordance, not the feature. A failure
|
|
79
|
+
* here leaves `searchFacets` empty — the modal then simply offers no filters and
|
|
80
|
+
* searches the whole index, which is the default behavior anyway. Only a failing
|
|
81
|
+
* SEARCH is worth showing the reader an error for.
|
|
82
|
+
*/
|
|
83
|
+
async function loadFacets() {
|
|
84
|
+
if (facetsLoaded) return;
|
|
85
|
+
facetsLoaded = true;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(`${FACETS_ENDPOINT}?pageTypeId=${encodeURIComponent(currentPageTypeId)}`);
|
|
89
|
+
if (!response.ok) return;
|
|
90
|
+
const body = await response.json();
|
|
91
|
+
if (Array.isArray(body.facets)) {
|
|
92
|
+
searchFacets = body.facets;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Offline, blocked, or the endpoint is down — searching still works.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Escape text before it is interpolated into result markup. */
|
|
100
|
+
function escapeHtml(value) {
|
|
101
|
+
return String(value ?? "")
|
|
102
|
+
.replace(/&/g, "&")
|
|
103
|
+
.replace(/</g, "<")
|
|
104
|
+
.replace(/>/g, ">")
|
|
105
|
+
.replace(/"/g, """);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Query the live endpoint. Resolves to the parsed body, or throws so the caller
|
|
110
|
+
* can render the explicit unavailable state — a failed search must never look
|
|
111
|
+
* like an empty result set.
|
|
112
|
+
*
|
|
113
|
+
* The chips are baked in at publish time, so an index edited since the last
|
|
114
|
+
* publish can leave a chip the server no longer accepts: it rejects the filter
|
|
115
|
+
* with `400 invalid_filter` rather than quietly widening the search. That is the
|
|
116
|
+
* intended strictness, so the client recovers instead — it drops the filter and
|
|
117
|
+
* retries once, and the retry's authoritative `facets` remove the dead chip.
|
|
118
|
+
*/
|
|
119
|
+
async function requestSearch(query, mode, signal) {
|
|
120
|
+
const send = () => {
|
|
121
|
+
const params = new URLSearchParams({ q: query, pageTypeId: currentPageTypeId, mode });
|
|
122
|
+
if (activeFilter) {
|
|
123
|
+
params.append("pageTypeIds", activeFilter);
|
|
124
|
+
}
|
|
125
|
+
return fetch(`${SEARCH_ENDPOINT}?${params.toString()}`, { signal });
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
let response = await send();
|
|
129
|
+
if (response.status === 400 && activeFilter && (await isStaleFilter(response))) {
|
|
130
|
+
activeFilter = null;
|
|
131
|
+
response = await send();
|
|
132
|
+
}
|
|
133
|
+
// `invalid_page_type` is permanent: this page type is not a docs type, or has
|
|
134
|
+
// no search index configured. Retrying cannot fix it, and calling it
|
|
135
|
+
// "temporarily unavailable" on every keystroke is simply untrue. Latch it and
|
|
136
|
+
// fall silent — the same shape as a free-tier site with no index built.
|
|
137
|
+
if (response.status === 400 && (await isUnconfigured(response))) {
|
|
138
|
+
searchUnconfigured = true;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
throw new Error(`Search request failed: ${response.status}`);
|
|
143
|
+
}
|
|
144
|
+
return response.json();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How long a search will wait for the tracker to establish a session before
|
|
149
|
+
* querying anyway. The identity is a nice-to-have on the log row; the search is
|
|
150
|
+
* the thing the reader asked for, so it must never be held hostage to it.
|
|
151
|
+
*/
|
|
152
|
+
const SESSION_WAIT_MS = 1500;
|
|
153
|
+
|
|
154
|
+
/** Set once the first search has already waited — the session establishes once. */
|
|
155
|
+
let sessionSettled = false;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Give the tracker a chance to establish `__lid`/`__sid` before the first query.
|
|
159
|
+
*
|
|
160
|
+
* `whisper.js` mints the session with an ASYNC fingerprint round-trip, and the
|
|
161
|
+
* search endpoint only READS those cookies (it never mints a session of its own,
|
|
162
|
+
* by design — otherwise every debounced keystroke would write tracking rows). So
|
|
163
|
+
* a reader who opens search immediately on a cold visit would otherwise log
|
|
164
|
+
* queries with no `leadDetailsId`, and the query-to-reader attribution this
|
|
165
|
+
* feature exists to provide would silently be missing exactly where a reader's
|
|
166
|
+
* first search is the most interesting signal we have.
|
|
167
|
+
*
|
|
168
|
+
* `utilities.js` gates forms, recommendations and CTAs on the same global. This
|
|
169
|
+
* is deliberately best-effort: if the tracker is blocked, absent, or slow, the
|
|
170
|
+
* search proceeds anyway after SESSION_WAIT_MS and simply logs without the lead.
|
|
171
|
+
*/
|
|
172
|
+
async function awaitSessionReady() {
|
|
173
|
+
if (sessionSettled) return;
|
|
174
|
+
sessionSettled = true;
|
|
175
|
+
|
|
176
|
+
if (typeof window.leedSessionReady !== "function") return;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
await Promise.race([
|
|
180
|
+
// MUST be a SessionStartedEventName — the /api/lid body is validated
|
|
181
|
+
// against that enum, so an invented name ("docs_search") 400s and the
|
|
182
|
+
// session is never established. `utilities.js` passes "pageview" for its
|
|
183
|
+
// non-form triggers (recommendations, CTAs); search does the same.
|
|
184
|
+
window.leedSessionReady("pageview"),
|
|
185
|
+
new Promise((resolve) => setTimeout(resolve, SESSION_WAIT_MS)),
|
|
186
|
+
]);
|
|
187
|
+
} catch {
|
|
188
|
+
// A tracker failure must never block search.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** True when a 400 means this page type can never be searched, however often we ask. */
|
|
193
|
+
async function isUnconfigured(response) {
|
|
194
|
+
try {
|
|
195
|
+
return (await response.clone().json()).error === "invalid_page_type";
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** True when a 400 was caused by a filter the resolved index no longer contains. */
|
|
202
|
+
async function isStaleFilter(response) {
|
|
203
|
+
try {
|
|
204
|
+
return (await response.json()).error === "invalid_filter";
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Search UI integration for existing modal
|
|
212
|
+
*/
|
|
213
|
+
class SearchUI {
|
|
214
|
+
constructor(options = {}) {
|
|
215
|
+
this.searchInput = null;
|
|
216
|
+
this.searchResults = null;
|
|
217
|
+
this.searchFilters = null;
|
|
218
|
+
this.renderedChips = null;
|
|
219
|
+
this.searchTimeout = null;
|
|
220
|
+
this.saveTimeout = null;
|
|
221
|
+
this.isInitialized = false;
|
|
222
|
+
this.pendingRequest = null;
|
|
223
|
+
|
|
224
|
+
// Selection tracking
|
|
225
|
+
this.selectedIndex = -1;
|
|
226
|
+
this.selectableItems = [];
|
|
227
|
+
|
|
228
|
+
// Search history configuration
|
|
229
|
+
this.maxHistoryItems = options.maxHistoryItems || 5;
|
|
230
|
+
this.historyStorageKey = options.historyStorageKey || "searchHistory";
|
|
231
|
+
this.searchHistory = this.loadSearchHistory();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Initialize search UI with existing modal elements
|
|
236
|
+
*/
|
|
237
|
+
initialize() {
|
|
238
|
+
this.searchInput = document.querySelector('#document-search-modal input[type="text"]');
|
|
239
|
+
this.searchResults = document.querySelector("#document-search-modal .search-results-container");
|
|
240
|
+
this.searchFilters = document.querySelector("#document-search-modal .search-filter-chips");
|
|
241
|
+
|
|
242
|
+
if (!this.searchInput || !this.searchResults) {
|
|
243
|
+
console.warn("Search modal elements not found");
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this.setupEventListeners();
|
|
248
|
+
this.renderFilters();
|
|
249
|
+
this.showRecentSearches();
|
|
250
|
+
this.setupModalOpenListener();
|
|
251
|
+
|
|
252
|
+
this.isInitialized = true;
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Setup listener for when modal opens to clear input
|
|
258
|
+
*/
|
|
259
|
+
setupModalOpenListener() {
|
|
260
|
+
const searchTriggers = document.querySelectorAll("#document-search, #document-search-container");
|
|
261
|
+
searchTriggers.forEach((trigger) => {
|
|
262
|
+
trigger.addEventListener("click", () => {
|
|
263
|
+
this.loadFilters();
|
|
264
|
+
setTimeout(() => {
|
|
265
|
+
if (this.searchInput) {
|
|
266
|
+
this.searchInput.value = "";
|
|
267
|
+
this.searchInput.focus();
|
|
268
|
+
this.showRecentSearches();
|
|
269
|
+
}
|
|
270
|
+
}, 100);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Setup event listeners for search functionality
|
|
277
|
+
*/
|
|
278
|
+
setupEventListeners() {
|
|
279
|
+
this.searchInput.addEventListener("input", (e) => {
|
|
280
|
+
const query = e.target.value.trim();
|
|
281
|
+
this.handleSearchInput(query);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
this.searchInput.addEventListener("keydown", (e) => {
|
|
285
|
+
if (e.key === "Enter") {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
this.handleEnterKey();
|
|
288
|
+
} else if (e.key === "ArrowDown") {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
this.navigateSelection(1);
|
|
291
|
+
} else if (e.key === "ArrowUp") {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
this.navigateSelection(-1);
|
|
294
|
+
} else if (e.key === "Escape") {
|
|
295
|
+
// Let Alpine.js handle the escape key
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
this.searchResults.addEventListener("click", (e) => {
|
|
301
|
+
const resultItem = e.target.closest("[data-url]");
|
|
302
|
+
if (resultItem) {
|
|
303
|
+
const url = resultItem.getAttribute("data-url");
|
|
304
|
+
this.addToSearchHistory(this.searchInput.value.trim());
|
|
305
|
+
this.navigateToUrl(url);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const historyItem = e.target.closest("[data-query]");
|
|
310
|
+
if (historyItem) {
|
|
311
|
+
const query = historyItem.getAttribute("data-query");
|
|
312
|
+
this.searchInput.value = query;
|
|
313
|
+
this.performSearch(query, "hybrid", true);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
if (this.searchFilters) {
|
|
318
|
+
this.searchFilters.addEventListener("click", (e) => {
|
|
319
|
+
const chip = e.target.closest("[data-page-type-id]");
|
|
320
|
+
if (!chip) return;
|
|
321
|
+
const id = chip.getAttribute("data-page-type-id");
|
|
322
|
+
activeFilter = activeFilter === id ? null : id;
|
|
323
|
+
this.renderFilters();
|
|
324
|
+
// A keystroke's debounce may still be pending; letting it fire would
|
|
325
|
+
// abort this scoped query and replace it with the unscoped one.
|
|
326
|
+
clearTimeout(this.searchTimeout);
|
|
327
|
+
const query = this.searchInput.value.trim();
|
|
328
|
+
if (query.length >= MIN_QUERY_LENGTH) {
|
|
329
|
+
this.performSearch(query, "freetext", false);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Render the page-type filter chips, marking the active one. The chip for the
|
|
337
|
+
* page the reader is on is offered first so scoping to the current doc set is
|
|
338
|
+
* one click; the whole index remains the default.
|
|
339
|
+
*/
|
|
340
|
+
/**
|
|
341
|
+
* Fetch the chips on first modal open rather than at page load: a reader who
|
|
342
|
+
* never searches costs nothing, and the response is browser-cached so opening
|
|
343
|
+
* search repeatedly costs nothing either.
|
|
344
|
+
*/
|
|
345
|
+
loadFilters() {
|
|
346
|
+
loadFacets().then(() => this.renderFilters());
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
renderFilters() {
|
|
350
|
+
if (!this.searchFilters) return;
|
|
351
|
+
// A single-set index has nothing to filter by.
|
|
352
|
+
const chips =
|
|
353
|
+
searchFacets.length <= 1
|
|
354
|
+
? ""
|
|
355
|
+
: searchFacets
|
|
356
|
+
.slice()
|
|
357
|
+
// Stable, so the remaining sets keep their published order.
|
|
358
|
+
.sort((a, b) => (a.id === currentPageTypeId ? -1 : 0) - (b.id === currentPageTypeId ? -1 : 0))
|
|
359
|
+
.map((facet) => {
|
|
360
|
+
const classes = ["search-filter-chip"];
|
|
361
|
+
if (facet.id === activeFilter) classes.push("is-active");
|
|
362
|
+
if (facet.id === currentPageTypeId) classes.push("is-current");
|
|
363
|
+
return `<button type="button" class="${classes.join(" ")}" data-page-type-id="${escapeHtml(facet.id)}">${escapeHtml(facet.name)}</button>`;
|
|
364
|
+
})
|
|
365
|
+
.join("");
|
|
366
|
+
|
|
367
|
+
// Every debounced response re-runs this; rewriting identical markup would
|
|
368
|
+
// churn the DOM ~3x/second while typing and drop focus from a chip.
|
|
369
|
+
if (chips !== this.renderedChips) {
|
|
370
|
+
this.searchFilters.innerHTML = chips;
|
|
371
|
+
this.renderedChips = chips;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Adopt the authoritative page types the server resolved. The chips come from
|
|
377
|
+
* `/api/search/facets`, which is browser-cached, so within that TTL an index
|
|
378
|
+
* edited in the CMS can still leave a chip for a page type that is gone. Every
|
|
379
|
+
* search response carries the real set, so adopting it here keeps the modal
|
|
380
|
+
* self-healing at no extra request. (Recovery from a request that USED such a
|
|
381
|
+
* chip happens earlier, in `requestSearch`'s 400 retry — a 200 always means the
|
|
382
|
+
* filter was valid.)
|
|
383
|
+
*/
|
|
384
|
+
reconcileFacets(facets) {
|
|
385
|
+
if (!Array.isArray(facets)) return;
|
|
386
|
+
searchFacets = facets;
|
|
387
|
+
this.renderFilters();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Handle search input with debouncing. Typing only ever runs the cheap
|
|
392
|
+
* freetext query; the semantic path is reserved for a deliberate Enter.
|
|
393
|
+
*/
|
|
394
|
+
handleSearchInput(query) {
|
|
395
|
+
clearTimeout(this.searchTimeout);
|
|
396
|
+
clearTimeout(this.saveTimeout);
|
|
397
|
+
|
|
398
|
+
this.searchTimeout = setTimeout(() => {
|
|
399
|
+
if (query.length >= MIN_QUERY_LENGTH) {
|
|
400
|
+
this.performSearch(query, "freetext", false);
|
|
401
|
+
} else {
|
|
402
|
+
this.showRecentSearches();
|
|
403
|
+
}
|
|
404
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
405
|
+
|
|
406
|
+
if (query.length >= MIN_QUERY_LENGTH) {
|
|
407
|
+
this.saveTimeout = setTimeout(() => {
|
|
408
|
+
this.addToSearchHistory(query);
|
|
409
|
+
}, 2000);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Handle Enter key press. A highlighted result still navigates; otherwise
|
|
415
|
+
* Enter is the deliberate-search trigger and runs one hybrid query, rather
|
|
416
|
+
* than jumping to the first hit as the lunr client did.
|
|
417
|
+
*/
|
|
418
|
+
handleEnterKey() {
|
|
419
|
+
// A debounced freetext query is almost always pending when Enter lands. If
|
|
420
|
+
// it fired afterwards it would abort the hybrid request and overwrite the
|
|
421
|
+
// deliberate semantic results — throwing away the one embedding we spent.
|
|
422
|
+
clearTimeout(this.searchTimeout);
|
|
423
|
+
clearTimeout(this.saveTimeout);
|
|
424
|
+
|
|
425
|
+
const query = this.searchInput.value.trim();
|
|
426
|
+
|
|
427
|
+
if (this.selectedIndex >= 0 && this.selectedIndex < this.selectableItems.length) {
|
|
428
|
+
const selectedItem = this.selectableItems[this.selectedIndex];
|
|
429
|
+
const url = selectedItem.getAttribute("data-url");
|
|
430
|
+
const historyQuery = selectedItem.getAttribute("data-query");
|
|
431
|
+
|
|
432
|
+
if (url) {
|
|
433
|
+
this.addToSearchHistory(query);
|
|
434
|
+
this.navigateToUrl(url);
|
|
435
|
+
return;
|
|
436
|
+
} else if (historyQuery && query.length < MIN_QUERY_LENGTH) {
|
|
437
|
+
// A recent-search row is only the reader's intent while they have not
|
|
438
|
+
// typed a searchable query. Recent searches stay on screen during the
|
|
439
|
+
// 300 ms debounce, still auto-selected — so without this length check,
|
|
440
|
+
// Enter right after typing runs the OLD search instead of the words just
|
|
441
|
+
// typed, and spends the embedding on them.
|
|
442
|
+
this.searchInput.value = historyQuery;
|
|
443
|
+
this.performSearch(historyQuery, "hybrid", true);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (query.length < MIN_QUERY_LENGTH) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
this.addToSearchHistory(query);
|
|
453
|
+
this.performSearch(query, "hybrid", false);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Navigate selection with arrow keys
|
|
458
|
+
*/
|
|
459
|
+
navigateSelection(direction) {
|
|
460
|
+
if (this.selectableItems.length === 0) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
this.selectedIndex += direction;
|
|
465
|
+
|
|
466
|
+
if (this.selectedIndex < 0) {
|
|
467
|
+
this.selectedIndex = this.selectableItems.length - 1;
|
|
468
|
+
} else if (this.selectedIndex >= this.selectableItems.length) {
|
|
469
|
+
this.selectedIndex = 0;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
this.updateSelection();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Update visual selection highlighting
|
|
477
|
+
*/
|
|
478
|
+
updateSelection() {
|
|
479
|
+
this.selectableItems.forEach((item) => {
|
|
480
|
+
item.classList.remove("search-result-selected");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
if (this.selectedIndex >= 0 && this.selectedIndex < this.selectableItems.length) {
|
|
484
|
+
const selectedItem = this.selectableItems[this.selectedIndex];
|
|
485
|
+
selectedItem.classList.add("search-result-selected");
|
|
486
|
+
|
|
487
|
+
selectedItem.scrollIntoView({
|
|
488
|
+
behavior: "smooth",
|
|
489
|
+
block: "nearest",
|
|
490
|
+
inline: "nearest",
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Reset selection
|
|
497
|
+
*/
|
|
498
|
+
resetSelection() {
|
|
499
|
+
this.selectedIndex = -1;
|
|
500
|
+
this.selectableItems = [];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Navigate to a URL, handling current page case by closing modal
|
|
505
|
+
*/
|
|
506
|
+
navigateToUrl(url) {
|
|
507
|
+
const currentUrl = window.location.pathname + window.location.search + window.location.hash;
|
|
508
|
+
|
|
509
|
+
if (url === currentUrl || url === window.location.href) {
|
|
510
|
+
this.closeModal();
|
|
511
|
+
} else {
|
|
512
|
+
const currentPathAndQuery = window.location.pathname + window.location.search;
|
|
513
|
+
const targetPathAndQuery = url.split("#")[0];
|
|
514
|
+
|
|
515
|
+
if (currentPathAndQuery === targetPathAndQuery) {
|
|
516
|
+
window.location.href = url;
|
|
517
|
+
setTimeout(() => this.closeModal(), 100);
|
|
518
|
+
} else {
|
|
519
|
+
window.location.href = url;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Close the search modal
|
|
526
|
+
*/
|
|
527
|
+
closeModal() {
|
|
528
|
+
const searchModal = document.getElementById("document-search-modal");
|
|
529
|
+
if (searchModal && window.Alpine) {
|
|
530
|
+
// Find the parent Alpine.js component that contains headerSearchShow
|
|
531
|
+
// The headerSearchShow variable is in the header component, not the modal
|
|
532
|
+
let currentElement = searchModal;
|
|
533
|
+
let alpineData = null;
|
|
534
|
+
|
|
535
|
+
while (currentElement && currentElement !== document.body) {
|
|
536
|
+
alpineData = Alpine.$data(currentElement);
|
|
537
|
+
if (alpineData && typeof alpineData.headerSearchShow !== "undefined") {
|
|
538
|
+
alpineData.headerSearchShow = false;
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
currentElement = currentElement.parentElement;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!alpineData || typeof alpineData.headerSearchShow === "undefined") {
|
|
545
|
+
const allElements = document.querySelectorAll("[x-data]");
|
|
546
|
+
for (const element of allElements) {
|
|
547
|
+
const data = Alpine.$data(element);
|
|
548
|
+
if (data && typeof data.headerSearchShow !== "undefined") {
|
|
549
|
+
data.headerSearchShow = false;
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Run one query against the live endpoint and render it. Any in-flight request
|
|
559
|
+
* is aborted first, so a fast typist never sees an earlier response overwrite a
|
|
560
|
+
* later one.
|
|
561
|
+
*/
|
|
562
|
+
async performSearch(query, mode, saveToHistory = false) {
|
|
563
|
+
// Permanently unconfigured: stop asking. Every further keystroke would buy
|
|
564
|
+
// the same 400.
|
|
565
|
+
if (searchUnconfigured) {
|
|
566
|
+
this.showRecentSearches();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (this.pendingRequest) {
|
|
571
|
+
this.pendingRequest.abort();
|
|
572
|
+
}
|
|
573
|
+
const controller = new AbortController();
|
|
574
|
+
this.pendingRequest = controller;
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
// Only ever waits on the first query of a visit; a no-op thereafter.
|
|
578
|
+
await awaitSessionReady();
|
|
579
|
+
if (controller.signal.aborted) return;
|
|
580
|
+
|
|
581
|
+
const body = await requestSearch(query, mode, controller.signal);
|
|
582
|
+
// `null` means the endpoint reported a permanent misconfiguration and the
|
|
583
|
+
// latch is now set. Leave the panel as it was rather than asserting either
|
|
584
|
+
// "no results" or "temporarily unavailable" — neither is true.
|
|
585
|
+
if (body === null) {
|
|
586
|
+
this.showRecentSearches();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
this.reconcileFacets(body.facets);
|
|
590
|
+
// `note` explains a hybrid→freetext downgrade, so the reader knows why a
|
|
591
|
+
// deliberate search came back with keyword-only results.
|
|
592
|
+
this.displayResults(Array.isArray(body.results) ? body.results : [], query, body.note ?? "");
|
|
593
|
+
if (saveToHistory) {
|
|
594
|
+
this.addToSearchHistory(query);
|
|
595
|
+
}
|
|
596
|
+
} catch (error) {
|
|
597
|
+
// `signal.aborted` also covers an abort swallowed while reading a 400 body.
|
|
598
|
+
if (error.name === "AbortError" || controller.signal.aborted) return;
|
|
599
|
+
console.error("Search error:", error);
|
|
600
|
+
this.displayUnavailable();
|
|
601
|
+
} finally {
|
|
602
|
+
if (this.pendingRequest === controller) {
|
|
603
|
+
this.pendingRequest = null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Load search history from localStorage
|
|
610
|
+
*/
|
|
611
|
+
loadSearchHistory() {
|
|
612
|
+
try {
|
|
613
|
+
const history = localStorage.getItem(this.historyStorageKey);
|
|
614
|
+
return history ? JSON.parse(history) : [];
|
|
615
|
+
} catch (error) {
|
|
616
|
+
console.warn("Failed to load search history:", error);
|
|
617
|
+
return [];
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Save search history to localStorage
|
|
623
|
+
*/
|
|
624
|
+
saveSearchHistory() {
|
|
625
|
+
try {
|
|
626
|
+
localStorage.setItem(this.historyStorageKey, JSON.stringify(this.searchHistory));
|
|
627
|
+
} catch (error) {
|
|
628
|
+
console.warn("Failed to save search history:", error);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Add a search query to history
|
|
634
|
+
*/
|
|
635
|
+
addToSearchHistory(query) {
|
|
636
|
+
if (!query || query.trim().length < MIN_QUERY_LENGTH) return;
|
|
637
|
+
|
|
638
|
+
const trimmedQuery = query.trim();
|
|
639
|
+
|
|
640
|
+
this.searchHistory = this.searchHistory.filter((item) => item.query !== trimmedQuery);
|
|
641
|
+
|
|
642
|
+
this.searchHistory.unshift({
|
|
643
|
+
query: trimmedQuery,
|
|
644
|
+
timestamp: Date.now(),
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
this.searchHistory = this.searchHistory.slice(0, this.maxHistoryItems);
|
|
648
|
+
|
|
649
|
+
this.saveSearchHistory();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Clear search history
|
|
654
|
+
*/
|
|
655
|
+
clearSearchHistory() {
|
|
656
|
+
this.searchHistory = [];
|
|
657
|
+
this.saveSearchHistory();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The explicit "search is temporarily unavailable" state. A failed, rate-limited
|
|
662
|
+
* or disabled endpoint must never be shown as "no results" — that would read as
|
|
663
|
+
* a statement about the docs rather than about the service.
|
|
664
|
+
*/
|
|
665
|
+
displayUnavailable() {
|
|
666
|
+
this.resetSelection();
|
|
667
|
+
this.searchResults.innerHTML = `
|
|
668
|
+
<div class="search-unavailable">
|
|
669
|
+
<div class="search-no-results-title">Search is temporarily unavailable</div>
|
|
670
|
+
<div class="search-no-results-subtitle">Please try again in a moment.</div>
|
|
671
|
+
</div>
|
|
672
|
+
`;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Display search results
|
|
677
|
+
*/
|
|
678
|
+
displayResults(results, query, note = "") {
|
|
679
|
+
this.resetSelection();
|
|
680
|
+
|
|
681
|
+
if (results.length === 0) {
|
|
682
|
+
// A downgraded search that found nothing still has to say so: otherwise an
|
|
683
|
+
// empty freetext fallback reads as a statement about the docs rather than
|
|
684
|
+
// about the weaker query the reader actually got.
|
|
685
|
+
this.searchResults.innerHTML = `
|
|
686
|
+
<div class="search-no-results">
|
|
687
|
+
<div class="search-no-results-title">No results found for "${escapeHtml(query)}"</div>
|
|
688
|
+
<div class="search-no-results-subtitle">Try different keywords or check your spelling</div>
|
|
689
|
+
${note ? `<div class="search-degraded-note">${escapeHtml(note)}</div>` : ""}
|
|
690
|
+
</div>
|
|
691
|
+
`;
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
this.searchResults.innerHTML = `
|
|
696
|
+
<div class="search-results-header">Search results (${results.length})</div>
|
|
697
|
+
${note ? `<div class="search-degraded-note">${escapeHtml(note)}</div>` : ""}
|
|
698
|
+
${results.map((result) => this.createResultHTML(result, query)).join("")}
|
|
699
|
+
`;
|
|
700
|
+
|
|
701
|
+
// Deliberately leaves selectedIndex at -1 (reset above). Auto-selecting the
|
|
702
|
+
// first hit here would make Enter navigate to it, because handleEnterKey
|
|
703
|
+
// honors an existing selection before running a search — which would mean
|
|
704
|
+
// the hybrid query never fires and the semantic path is unreachable. Arrow
|
|
705
|
+
// keys still select; Enter with nothing selected runs the deliberate search.
|
|
706
|
+
this.selectableItems = Array.from(this.searchResults.querySelectorAll("[data-url]"));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Create HTML for a single search result. A semantic-only hit carries no
|
|
711
|
+
* snippet (the snippet comes from the full-text row), so its summary block is
|
|
712
|
+
* omitted entirely rather than rendered empty.
|
|
713
|
+
*/
|
|
714
|
+
createResultHTML(result, query = "") {
|
|
715
|
+
const breadcrumb = this.createBreadcrumb(result);
|
|
716
|
+
const badge = this.createMatchBadge(result);
|
|
717
|
+
const highlightedTitle = this.highlightKeywords(escapeHtml(result.title), query);
|
|
718
|
+
const summary = result.snippet
|
|
719
|
+
? `<div class="search-result-summary">${this.highlightKeywords(escapeHtml(result.snippet), query)}</div>`
|
|
720
|
+
: "";
|
|
721
|
+
|
|
722
|
+
return `
|
|
723
|
+
<div class="searched-item" data-url="${escapeHtml(result.url || result.path)}">
|
|
724
|
+
<i class="search-result-icon"></i>
|
|
725
|
+
<div class="search-result-content">
|
|
726
|
+
<div class="search-result-breadcrumb">
|
|
727
|
+
${breadcrumb}
|
|
728
|
+
${badge}
|
|
729
|
+
</div>
|
|
730
|
+
<div class="search-result-title">${highlightedTitle}</div>
|
|
731
|
+
${summary}
|
|
732
|
+
</div>
|
|
733
|
+
<i class="search-result-goto-icon"></i>
|
|
734
|
+
</div>
|
|
735
|
+
`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Whether the hit came from the keyword index, the semantic index, or both —
|
|
740
|
+
* the reader's cue for why a result with no obvious word match is there.
|
|
741
|
+
*/
|
|
742
|
+
createMatchBadge(result) {
|
|
743
|
+
const labels = { keyword: "Keyword", semantic: "Semantic", both: "Keyword + semantic" };
|
|
744
|
+
// `hasOwn`, not a bare lookup: `matchType` is server-supplied, and
|
|
745
|
+
// `labels["constructor"]` would otherwise render a function body.
|
|
746
|
+
if (!Object.hasOwn(labels, result.matchType)) return "";
|
|
747
|
+
return `<span class="search-match-badge">${labels[result.matchType]}</span>`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Breadcrumb for a search result: the page type of the doc set it belongs to,
|
|
752
|
+
* followed by the path segments leading to the page.
|
|
753
|
+
*/
|
|
754
|
+
createBreadcrumb(result) {
|
|
755
|
+
const facet = searchFacets.find((f) => f.id === result.pageTypeId);
|
|
756
|
+
// The path STARTS with the set's slug — that is how the server attributed
|
|
757
|
+
// the hit — so those segments are dropped in favor of the set's name.
|
|
758
|
+
const rootDepth = facet?.slug ? facet.slug.split("/").filter(Boolean).length : 0;
|
|
759
|
+
const segments = (result.path || "")
|
|
760
|
+
.split("/")
|
|
761
|
+
.filter(Boolean)
|
|
762
|
+
.slice(rootDepth, -1)
|
|
763
|
+
.map((segment) => segment.replace(/[-_]/g, " "));
|
|
764
|
+
const crumbs = facet ? [facet.name, ...segments] : segments;
|
|
765
|
+
|
|
766
|
+
return crumbs.map((crumb) => `<span class="search-crumb">${escapeHtml(crumb)}</span>`).join("");
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Highlight matching keywords in text
|
|
771
|
+
*/
|
|
772
|
+
highlightKeywords(text, query) {
|
|
773
|
+
if (!query || !text) return text;
|
|
774
|
+
|
|
775
|
+
// Split query into individual words and filter out common words
|
|
776
|
+
const queryWords = query
|
|
777
|
+
.toLowerCase()
|
|
778
|
+
.split(/\s+/)
|
|
779
|
+
.filter((word) => word.length > 2)
|
|
780
|
+
.filter((word) => !STOP_WORDS.includes(word));
|
|
781
|
+
|
|
782
|
+
if (queryWords.length === 0) {
|
|
783
|
+
return text;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const pattern = new RegExp(`(${queryWords.map((word) => this.escapeRegExp(word)).join("|")})`, "gi");
|
|
787
|
+
|
|
788
|
+
return text.replace(pattern, '<span class="search-highlight">$1</span>');
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Escape special regex characters
|
|
793
|
+
*/
|
|
794
|
+
escapeRegExp(string) {
|
|
795
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Show recent searches from history
|
|
800
|
+
*/
|
|
801
|
+
showRecentSearches() {
|
|
802
|
+
this.resetSelection();
|
|
803
|
+
|
|
804
|
+
if (this.searchHistory.length === 0) {
|
|
805
|
+
this.searchResults.innerHTML = `
|
|
806
|
+
<div class="search-recent-header">Recent searches</div>
|
|
807
|
+
<div class="search-no-results">
|
|
808
|
+
<div class="search-no-results-subtitle">No recent searches</div>
|
|
809
|
+
</div>
|
|
810
|
+
`;
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const historyHTML = this.searchHistory
|
|
815
|
+
.map((item) => {
|
|
816
|
+
const timeAgo = this.getTimeAgo(item.timestamp);
|
|
817
|
+
return `
|
|
818
|
+
<div class="searched-item search-history-item" data-query="${escapeHtml(item.query)}">
|
|
819
|
+
<i class="search-history-icon"></i>
|
|
820
|
+
<div class="search-result-content">
|
|
821
|
+
<div class="search-result-title">${escapeHtml(item.query)}</div>
|
|
822
|
+
<div class="search-result-summary">Searched ${timeAgo}</div>
|
|
823
|
+
</div>
|
|
824
|
+
<i class="search-result-goto-icon"></i>
|
|
825
|
+
</div>
|
|
826
|
+
`;
|
|
827
|
+
})
|
|
828
|
+
.join("");
|
|
829
|
+
|
|
830
|
+
this.searchResults.innerHTML = `
|
|
831
|
+
<div class="search-recent-header">Recent searches</div>
|
|
832
|
+
${historyHTML}
|
|
833
|
+
`;
|
|
834
|
+
|
|
835
|
+
this.selectableItems = Array.from(this.searchResults.querySelectorAll("[data-query]"));
|
|
836
|
+
|
|
837
|
+
if (this.selectableItems.length > 0) {
|
|
838
|
+
this.selectedIndex = 0;
|
|
839
|
+
this.updateSelection();
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Get human-readable time ago string
|
|
845
|
+
*/
|
|
846
|
+
getTimeAgo(timestamp) {
|
|
847
|
+
const now = Date.now();
|
|
848
|
+
const diff = now - timestamp;
|
|
849
|
+
const minutes = Math.floor(diff / 60000);
|
|
850
|
+
const hours = Math.floor(diff / 3600000);
|
|
851
|
+
const days = Math.floor(diff / 86400000);
|
|
852
|
+
|
|
853
|
+
if (minutes < 1) {
|
|
854
|
+
return "just now";
|
|
855
|
+
}
|
|
856
|
+
if (minutes < 60) {
|
|
857
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
858
|
+
}
|
|
859
|
+
if (hours < 24) {
|
|
860
|
+
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Short, high-frequency words that are noise when highlighting a match. */
|
|
868
|
+
const STOP_WORDS = [
|
|
869
|
+
"the",
|
|
870
|
+
"and",
|
|
871
|
+
"for",
|
|
872
|
+
"are",
|
|
873
|
+
"but",
|
|
874
|
+
"not",
|
|
875
|
+
"you",
|
|
876
|
+
"all",
|
|
877
|
+
"can",
|
|
878
|
+
"had",
|
|
879
|
+
"her",
|
|
880
|
+
"was",
|
|
881
|
+
"one",
|
|
882
|
+
"our",
|
|
883
|
+
"out",
|
|
884
|
+
"day",
|
|
885
|
+
"get",
|
|
886
|
+
"has",
|
|
887
|
+
"him",
|
|
888
|
+
"his",
|
|
889
|
+
"how",
|
|
890
|
+
"its",
|
|
891
|
+
"may",
|
|
892
|
+
"new",
|
|
893
|
+
"now",
|
|
894
|
+
"old",
|
|
895
|
+
"see",
|
|
896
|
+
"two",
|
|
897
|
+
"way",
|
|
898
|
+
"who",
|
|
899
|
+
"boy",
|
|
900
|
+
"did",
|
|
901
|
+
"man",
|
|
902
|
+
"oil",
|
|
903
|
+
"sit",
|
|
904
|
+
"try",
|
|
905
|
+
"use",
|
|
906
|
+
];
|
|
907
|
+
|
|
908
|
+
// Global search UI instance with configurable options
|
|
909
|
+
const searchUI = new SearchUI({
|
|
910
|
+
maxHistoryItems: 7, // Number of recent searches to keep
|
|
911
|
+
historyStorageKey: "leedSearchHistory", // localStorage key
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
// Auto-initialize search when DOM is ready
|
|
915
|
+
if (typeof document !== "undefined") {
|
|
916
|
+
function initializeSearchUI() {
|
|
917
|
+
if (!initializeSearch()) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
searchUI.initialize();
|
|
922
|
+
|
|
923
|
+
// Only add global Ctrl+K handler if search initialization was successful
|
|
924
|
+
document.addEventListener(
|
|
925
|
+
"keydown",
|
|
926
|
+
(e) => {
|
|
927
|
+
if (e.ctrlKey && e.key === "k") {
|
|
928
|
+
e.preventDefault();
|
|
929
|
+
e.stopPropagation();
|
|
930
|
+
|
|
931
|
+
const searchModal = document.getElementById("document-search-modal");
|
|
932
|
+
if (searchModal && window.Alpine) {
|
|
933
|
+
// Find the parent Alpine.js component that contains headerSearchShow
|
|
934
|
+
let currentElement = searchModal;
|
|
935
|
+
let alpineData = null;
|
|
936
|
+
|
|
937
|
+
while (currentElement && currentElement !== document.body) {
|
|
938
|
+
alpineData = Alpine.$data(currentElement);
|
|
939
|
+
if (alpineData && typeof alpineData.headerSearchShow !== "undefined") {
|
|
940
|
+
alpineData.headerSearchShow = true;
|
|
941
|
+
openSearchInput();
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
currentElement = currentElement.parentElement;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (!alpineData || typeof alpineData.headerSearchShow === "undefined") {
|
|
948
|
+
const allElements = document.querySelectorAll("[x-data]");
|
|
949
|
+
for (const element of allElements) {
|
|
950
|
+
const data = Alpine.$data(element);
|
|
951
|
+
if (data && typeof data.headerSearchShow !== "undefined") {
|
|
952
|
+
data.headerSearchShow = true;
|
|
953
|
+
openSearchInput();
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
},
|
|
961
|
+
true,
|
|
962
|
+
); // Use capture phase to intercept before other handlers
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** Clear the input and show recent searches once the modal has been revealed. */
|
|
966
|
+
function openSearchInput() {
|
|
967
|
+
// Ctrl+K opens the modal without touching the trigger, so the chips have to
|
|
968
|
+
// be requested here too. Both paths are idempotent — `loadFacets` fetches
|
|
969
|
+
// at most once per page view.
|
|
970
|
+
searchUI.loadFilters();
|
|
971
|
+
setTimeout(() => {
|
|
972
|
+
const searchInput = document.querySelector('#document-search-modal input[type="text"]');
|
|
973
|
+
if (searchInput) {
|
|
974
|
+
searchInput.value = "";
|
|
975
|
+
searchInput.focus();
|
|
976
|
+
searchUI.showRecentSearches();
|
|
977
|
+
}
|
|
978
|
+
}, 50);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (document.readyState === "loading") {
|
|
982
|
+
document.addEventListener("DOMContentLoaded", initializeSearchUI);
|
|
983
|
+
} else {
|
|
984
|
+
initializeSearchUI();
|
|
985
|
+
}
|
|
986
|
+
}
|