@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.
Files changed (263) hide show
  1. package/LICENSE.md +57 -0
  2. package/README.md +120 -3
  3. package/dist/LICENSE.md +57 -0
  4. package/dist/acorn-DAgDT1om.js +5087 -0
  5. package/dist/angular-l0_4PK8N.js +3598 -0
  6. package/dist/authShared-Dray38Ls.js +1161 -0
  7. package/dist/babel-CJeRQd5u.js +10038 -0
  8. package/dist/buildResults-tiq1nugP.js +21180 -0
  9. package/dist/chunk-1VJ9lAw7.js +42 -0
  10. package/dist/cliAuth-38JjD6hw.js +15 -0
  11. package/dist/env-B0bHiXJJ.js +29 -0
  12. package/dist/errors-CqKamGCu.js +481 -0
  13. package/dist/estree-E8WG8UHj.js +6930 -0
  14. package/dist/flow-6xaRIbPQ.js +52255 -0
  15. package/dist/glimmer-aJPjsfbT.js +7272 -0
  16. package/dist/graphql-mcgtPEiZ.js +1901 -0
  17. package/dist/html--eeea3Kl.js +6553 -0
  18. package/dist/leed.eleventy.mjs +6525 -0
  19. package/dist/leed.mjs +88952 -0
  20. package/dist/leed.sources.json +798 -0
  21. package/dist/leedLogger-Zkada5aO.js +78 -0
  22. package/dist/markdown-D73jNKwx.js +6836 -0
  23. package/dist/meriyah-CMp1nDDW.js +8932 -0
  24. package/dist/package.json +101 -0
  25. package/dist/postcss-h4fBr2Fn.js +6760 -0
  26. package/dist/prettier-CwFSKxfN.js +16821 -0
  27. package/dist/static/customer-site-worker/package.json +9 -0
  28. package/dist/static/customer-site-worker/src/assetsHydration.ts +55 -0
  29. package/dist/static/customer-site-worker/src/constants.ts +20 -0
  30. package/dist/static/customer-site-worker/src/cors.ts +120 -0
  31. package/dist/static/customer-site-worker/src/index.ts +242 -0
  32. package/dist/static/customer-site-worker/src/mcp-proxy.ts +98 -0
  33. package/dist/static/customer-site-worker/src/proxy.ts +92 -0
  34. package/dist/static/customer-site-worker/src/proxyHeaders.ts +47 -0
  35. package/dist/static/customer-site-worker/src/tier.ts +40 -0
  36. package/dist/static/customer-site-worker/src/types.ts +82 -0
  37. package/dist/static/customer-site-worker/test/functions-mock.worker.js +50 -0
  38. package/dist/static/customer-site-worker/tsconfig.json +19 -0
  39. package/dist/static/customer-site-worker/vitest.workers.config.ts +61 -0
  40. package/dist/static/install/husky/colors.mjs +5 -0
  41. package/dist/static/install/husky/hooks/commit-msg +1 -0
  42. package/dist/static/install/husky/hooks/pre-commit +1 -0
  43. package/dist/static/install/husky/hooks/pre-push +1 -0
  44. package/dist/static/install/husky/valid-command.mjs +29 -0
  45. package/dist/static/install/husky/valid-commit-msg.mjs +20 -0
  46. package/dist/static/install/package.json +10 -0
  47. package/dist/static/install/packages.txt +4 -0
  48. package/dist/static/install/tasks.json +111 -0
  49. package/dist/static/javascript/detectIncognito.js +195 -0
  50. package/dist/static/javascript/documentation.js +2781 -0
  51. package/dist/static/javascript/fp/fp.esm.js +2896 -0
  52. package/dist/static/javascript/fp/fp.js +3067 -0
  53. package/dist/static/javascript/live-search.js +986 -0
  54. package/dist/static/javascript/lunr-search.js +843 -0
  55. package/dist/static/javascript/timeme.js +451 -0
  56. package/dist/static/javascript/tracker/webmcp.js +67 -0
  57. package/dist/static/javascript/tracker/whisper.js +1 -0
  58. package/dist/static/javascript/utilities.js +819 -0
  59. package/dist/static/javascript/versions.json +74 -0
  60. package/dist/static/javascript/zoomable.js +488 -0
  61. package/dist/static/site/_data/eleventyComputed.js +228 -0
  62. package/dist/static/site/_data/leed-form-data.json +253 -0
  63. package/dist/static/site/_includes/leed/api/bits/array-type.hbs +24 -0
  64. package/dist/static/site/_includes/leed/api/bits/badges.hbs +9 -0
  65. package/dist/static/site/_includes/leed/api/bits/description.hbs +9 -0
  66. package/dist/static/site/_includes/leed/api/bits/examples.hbs +42 -0
  67. package/dist/static/site/_includes/leed/api/bits/scopes.hbs +28 -0
  68. package/dist/static/site/_includes/leed/api/object.hbs +110 -0
  69. package/dist/static/site/_includes/leed/api/parameters.hbs +11 -0
  70. package/dist/static/site/_includes/leed/api/property.hbs +153 -0
  71. package/dist/static/site/_includes/leed/api/request-body.hbs +51 -0
  72. package/dist/static/site/_includes/leed/api/responses.hbs +89 -0
  73. package/dist/static/site/_includes/leed/api/security-constraints.hbs +59 -0
  74. package/dist/static/site/_includes/leed/api/security.hbs +75 -0
  75. package/dist/static/site/_includes/leed/components/pagination.hbs +45 -0
  76. package/dist/static/site/_includes/leed/components/scroll-status-indicator-horizontal.hbs +22 -0
  77. package/dist/static/site/_includes/leed/docs/alpha/api.hbs +6 -0
  78. package/dist/static/site/_includes/leed/docs/alpha/base.hbs +26 -0
  79. package/dist/static/site/_includes/leed/docs/alpha/documentation.hbs +7 -0
  80. package/dist/static/site/_includes/leed/docs/api-article.hbs +75 -0
  81. package/dist/static/site/_includes/leed/docs/bravo/api.hbs +5 -0
  82. package/dist/static/site/_includes/leed/docs/bravo/base.hbs +31 -0
  83. package/dist/static/site/_includes/leed/docs/bravo/documentation.hbs +7 -0
  84. package/dist/static/site/_includes/leed/docs/charlie/api.hbs +6 -0
  85. package/dist/static/site/_includes/leed/docs/charlie/base.hbs +27 -0
  86. package/dist/static/site/_includes/leed/docs/charlie/documentation.hbs +10 -0
  87. package/dist/static/site/_includes/leed/docs/doc-article.hbs +40 -0
  88. package/dist/static/site/_includes/leed/docs/docs-footer.hbs +35 -0
  89. package/dist/static/site/_includes/leed/docs/docs-header.hbs +59 -0
  90. package/dist/static/site/_includes/leed/docs/document-search-modal.hbs +36 -0
  91. package/dist/static/site/_includes/leed/docs/document-search.hbs +23 -0
  92. package/dist/static/site/_includes/leed/docs/footer.hbs +12 -0
  93. package/dist/static/site/_includes/leed/docs/header/brand.hbs +19 -0
  94. package/dist/static/site/_includes/leed/docs/header/chrome.hbs +33 -0
  95. package/dist/static/site/_includes/leed/docs/header/compact.hbs +19 -0
  96. package/dist/static/site/_includes/leed/docs/header/cta.hbs +15 -0
  97. package/dist/static/site/_includes/leed/docs/header/nav.hbs +18 -0
  98. package/dist/static/site/_includes/leed/docs/header/top-search.hbs +15 -0
  99. package/dist/static/site/_includes/leed/docs/mini-bar.hbs +53 -0
  100. package/dist/static/site/_includes/leed/docs/pagination.hbs +19 -0
  101. package/dist/static/site/_includes/leed/docs/shell.hbs +56 -0
  102. package/dist/static/site/_includes/leed/docs/sidebar.hbs +52 -0
  103. package/dist/static/site/_includes/leed/docs/social-icons.hbs +81 -0
  104. package/dist/static/site/_includes/leed/docs/style.hbs +17 -0
  105. package/dist/static/site/_includes/leed/docs/toc.hbs +7 -0
  106. package/dist/static/site/_includes/leed/form.hbs +266 -0
  107. package/dist/static/site/_includes/leed/head.hbs +67 -0
  108. package/dist/static/site/_includes/leed/menu/builder.hbs +35 -0
  109. package/dist/static/site/_includes/leed/menu/item.hbs +67 -0
  110. package/dist/static/site/_includes/leed/menu/link.hbs +59 -0
  111. package/dist/static/site/_includes/leed/metadata/ogCard.hbs +40 -0
  112. package/dist/static/site/_includes/leed/metadata/siteMetadata.hbs +24 -0
  113. package/dist/static/site/_includes/leed/metadata/twitterCard.hbs +34 -0
  114. package/dist/static/site/_includes/leed/popup-video.hbs +21 -0
  115. package/dist/static/site/_includes/leed/stub-template.hbs +17 -0
  116. package/dist/static/site/_includes/leed/unsubscribe-form.hbs +30 -0
  117. package/dist/static/site/_layouts/leed-documentation.hbs +12 -0
  118. package/dist/static/site/leed-feed-atom.hbs +42 -0
  119. package/dist/static/site/leed-feed-json.hbs +56 -0
  120. package/dist/static/site/leed-feed-rss.hbs +39 -0
  121. package/dist/static/site/leed-headers.hbs +38 -0
  122. package/dist/static/site/leed-redirects.hbs +10 -0
  123. package/dist/static/site/leed-sitemap-pageType.hbs +28 -0
  124. package/dist/static/site/leed-sitemap.hbs +19 -0
  125. package/dist/static/site/leed-unsubscribe.hbs +44 -0
  126. package/dist/static/site/leed-unsubscribed.hbs +40 -0
  127. package/dist/static/tailwind/components/alerts.css +132 -0
  128. package/dist/static/tailwind/components/api-methods.css +39 -0
  129. package/dist/static/tailwind/components/breadcrumbs.css +31 -0
  130. package/dist/static/tailwind/components/code.css +88 -0
  131. package/dist/static/tailwind/components/collapsible.css +38 -0
  132. package/dist/static/tailwind/components/document-search.css +222 -0
  133. package/dist/static/tailwind/components/form.css +185 -0
  134. package/dist/static/tailwind/components/kbd.css +7 -0
  135. package/dist/static/tailwind/components/pagination.css +81 -0
  136. package/dist/static/tailwind/components/scroll-status-indicator.css +5 -0
  137. package/dist/static/tailwind/components/tab-groups.css +117 -0
  138. package/dist/static/tailwind/components/tables.css +0 -0
  139. package/dist/static/tailwind/components/toc.css +60 -0
  140. package/dist/static/tailwind/components.css +248 -0
  141. package/dist/static/tailwind/defaults.css +260 -0
  142. package/dist/static/tailwind/docs/api.css +701 -0
  143. package/dist/static/tailwind/docs/components/blockquote.css +5 -0
  144. package/dist/static/tailwind/docs/components/button.css +11 -0
  145. package/dist/static/tailwind/docs/components/docs-pagination.css +71 -0
  146. package/dist/static/tailwind/docs/components/mobile-bar.css +83 -0
  147. package/dist/static/tailwind/docs/components/navigation.css +279 -0
  148. package/dist/static/tailwind/docs/components/table.css +54 -0
  149. package/dist/static/tailwind/docs/components.css +6 -0
  150. package/dist/static/tailwind/docs/docs.css +427 -0
  151. package/dist/static/tailwind/docs/layouts/alpha.css +108 -0
  152. package/dist/static/tailwind/docs/layouts/bravo.css +218 -0
  153. package/dist/static/tailwind/docs/layouts/charlie.css +105 -0
  154. package/dist/static/tailwind/docs/layouts.css +3 -0
  155. package/dist/static/tailwind/docs/themes/amber.css +113 -0
  156. package/dist/static/tailwind/docs/themes/blue.css +94 -0
  157. package/dist/static/tailwind/docs/themes/cyan.css +111 -0
  158. package/dist/static/tailwind/docs/themes/emerald.css +111 -0
  159. package/dist/static/tailwind/docs/themes/fuchsia.css +111 -0
  160. package/dist/static/tailwind/docs/themes/green.css +95 -0
  161. package/dist/static/tailwind/docs/themes/indigo.css +111 -0
  162. package/dist/static/tailwind/docs/themes/lime.css +111 -0
  163. package/dist/static/tailwind/docs/themes/orange.css +111 -0
  164. package/dist/static/tailwind/docs/themes/pink.css +111 -0
  165. package/dist/static/tailwind/docs/themes/purple.css +111 -0
  166. package/dist/static/tailwind/docs/themes/red.css +111 -0
  167. package/dist/static/tailwind/docs/themes/rose.css +111 -0
  168. package/dist/static/tailwind/docs/themes/sky.css +111 -0
  169. package/dist/static/tailwind/docs/themes/teal.css +111 -0
  170. package/dist/static/tailwind/docs/themes/violet.css +111 -0
  171. package/dist/static/tailwind/docs/themes/yellow.css +111 -0
  172. package/dist/static/tailwind/docs/themes.css +84 -0
  173. package/dist/static/tailwind/font-awesome/free-7.3.1/brands.css +2455 -0
  174. package/dist/static/tailwind/font-awesome/free-7.3.1/fontawesome.css +8997 -0
  175. package/dist/static/tailwind/font-awesome/free-7.3.1/fonts.css +45 -0
  176. package/dist/static/tailwind/font-awesome/free-7.3.1/icons.json +2608 -0
  177. package/dist/static/tailwind/font-awesome/free-7.3.1.css +3 -0
  178. package/dist/static/tailwind/font-awesome/pro-7.0.0/brands.css +2215 -0
  179. package/dist/static/tailwind/font-awesome/pro-7.0.0/fontawesome.css +19281 -0
  180. package/dist/static/tailwind/font-awesome/pro-7.0.0/fonts.css +445 -0
  181. package/dist/static/tailwind/font-awesome/pro-7.0.0.css +3 -0
  182. package/dist/static/tailwind/font-awesome/pro-7.3.1/brands.css +2455 -0
  183. package/dist/static/tailwind/font-awesome/pro-7.3.1/fontawesome.css +20365 -0
  184. package/dist/static/tailwind/font-awesome/pro-7.3.1/fonts.css +589 -0
  185. package/dist/static/tailwind/font-awesome/pro-7.3.1.css +3 -0
  186. package/dist/static/tailwind/fonts/all.css +6 -0
  187. package/dist/static/tailwind/fonts/config.jsonc +31 -0
  188. package/dist/static/tailwind/fonts/geist-mono.css +142 -0
  189. package/dist/static/tailwind/fonts/geist.css +142 -0
  190. package/dist/static/tailwind/fonts/inter.css +215 -0
  191. package/dist/static/tailwind/fonts/jetbrains-mono.css +126 -0
  192. package/dist/static/tailwind/fonts/noto-sans.css +134 -0
  193. package/dist/static/tailwind/fonts/open-sans.css +89 -0
  194. package/dist/static/tailwind/highlighters.css +14 -0
  195. package/dist/static/tailwind/hljs/README.md +53 -0
  196. package/dist/static/tailwind/hljs/a11y.css +96 -0
  197. package/dist/static/tailwind/hljs/atom-fruit.css +90 -0
  198. package/dist/static/tailwind/hljs/atom-one.css +164 -0
  199. package/dist/static/tailwind/hljs/default.css +164 -0
  200. package/dist/static/tailwind/hljs/github.css +101 -0
  201. package/dist/static/tailwind/hljs/google-code.css +112 -0
  202. package/dist/static/tailwind/hljs/grayscale.css +115 -0
  203. package/dist/static/tailwind/hljs/gruvbox-hard.css +115 -0
  204. package/dist/static/tailwind/hljs/gruvbox-medium.css +115 -0
  205. package/dist/static/tailwind/hljs/gruvbox-soft.css +115 -0
  206. package/dist/static/tailwind/hljs/papercolor.css +120 -0
  207. package/dist/static/tailwind/hljs/qt-creator.css +91 -0
  208. package/dist/static/tailwind/hljs/summerfruit.css +115 -0
  209. package/dist/static/tailwind/hljs/tomorrow.css +115 -0
  210. package/dist/static/tailwind/leed.css +170 -0
  211. package/dist/static/tailwind/site-tokens.reference.css +105 -0
  212. package/dist/static/tailwind/utils/icons.css +102 -0
  213. package/dist/static/tailwind/utils/utilities.css +105 -0
  214. package/dist/static/templates/pages/PAGE.hbs +27 -0
  215. package/dist/static/templates/pages/PAGE_LAYOUT.hbs +19 -0
  216. package/dist/static/templates/pages/PAGE_TYPE_INDEX.hbs +89 -0
  217. package/dist/static/templates/pages/PAGINATED_PAGE_TYPE_ITEM.hbs +52 -0
  218. package/dist/static/templates/pages/PAGINATED_PAGE_TYPE_LIST.hbs +39 -0
  219. package/dist/static/templates/pages/PAGINATED_PAGE_TYPE_PAGINATION.hbs +11 -0
  220. package/dist/static/templates/raw-content/.ci/build.sh +58 -0
  221. package/dist/static/templates/raw-content/README.md +179 -0
  222. package/dist/static/templates/raw-content/_gitignore +50 -0
  223. package/dist/static/templates/raw-content/src/404.hbs +21 -0
  224. package/dist/static/templates/raw-content/src/_data/autolink.json +1 -0
  225. package/dist/static/templates/raw-content/src/_data/ctaList.json +1 -0
  226. package/dist/static/templates/raw-content/src/_data/leedForms.json +1 -0
  227. package/dist/static/templates/raw-content/src/_data/menu.json +1 -0
  228. package/dist/static/templates/raw-content/src/_includes/footer.hbs +18 -0
  229. package/dist/static/templates/raw-content/src/_includes/header-includes.hbs +25 -0
  230. package/dist/static/templates/raw-content/src/_includes/header.hbs +5 -0
  231. package/dist/static/templates/raw-content/src/_includes/site-template.hbs +23 -0
  232. package/dist/static/templates/raw-content/src/_includes/unsubscribe-DISABLED.hbs +17 -0
  233. package/dist/static/templates/raw-content/src/_includes/unsubscribed-DISABLED.hbs +13 -0
  234. package/dist/static/templates/raw-content/src/_layouts/empty.hbs +2 -0
  235. package/dist/static/templates/raw-content/src/index.hbs +21 -0
  236. package/dist/static/templates/raw-content/src/robots.hbs +9 -0
  237. package/dist/static/templates/raw-content/tailwind/site/base.css +44 -0
  238. package/dist/static/templates/raw-content/tailwind/site.config.css +3 -0
  239. package/dist/static/templates/sandbox/_gitignore +50 -0
  240. package/dist/static/templates/sandbox/identity.json +1 -0
  241. package/dist/static/templates/sandbox/src/404.hbs +21 -0
  242. package/dist/static/templates/sandbox/src/_data/autolink.json +1 -0
  243. package/dist/static/templates/sandbox/src/_data/ctaList.json +1 -0
  244. package/dist/static/templates/sandbox/src/_data/labelList.json +9 -0
  245. package/dist/static/templates/sandbox/src/_data/leedForms.json +20 -0
  246. package/dist/static/templates/sandbox/src/_data/menu.json +19 -0
  247. package/dist/static/templates/sandbox/src/_data/pageTypeList.json +14 -0
  248. package/dist/static/templates/sandbox/src/_data/searchIndexList.json +1 -0
  249. package/dist/static/templates/sandbox/src/_data/userList.json +11 -0
  250. package/dist/static/templates/sandbox/src/_includes/footer.hbs +18 -0
  251. package/dist/static/templates/sandbox/src/_includes/header-includes.hbs +25 -0
  252. package/dist/static/templates/sandbox/src/_includes/header.hbs +5 -0
  253. package/dist/static/templates/sandbox/src/_includes/site-template.hbs +23 -0
  254. package/dist/static/templates/sandbox/src/_layouts/empty.hbs +2 -0
  255. package/dist/static/templates/sandbox/src/index.hbs +20 -0
  256. package/dist/static/templates/sandbox/src/robots.hbs +9 -0
  257. package/dist/static/templates/sandbox/src/src.11tydata.json +39 -0
  258. package/dist/static/templates/sandbox/tailwind/site/base.css +44 -0
  259. package/dist/static/templates/sandbox/tailwind/site.config.css +3 -0
  260. package/dist/thrown-sukVkp_1.js +113 -0
  261. package/dist/typescript-D4T0V_Hn.js +23004 -0
  262. package/dist/yaml-9wR8GxF_.js +5236 -0
  263. 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, "&lt;")
104
+ .replace(/>/g, "&gt;")
105
+ .replace(/"/g, "&quot;");
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
+ }