hdoc-tools 0.62.5 → 0.63.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 (71) hide show
  1. package/editor/dist/assets/{index-BtxvGZHW.js → index-Dknn5g5A.js} +5 -4
  2. package/editor/dist/index.html +13 -13
  3. package/hdoc-build.js +5 -3
  4. package/hdoc-content-routes.js +136 -4
  5. package/hdoc-serve.js +26 -3
  6. package/package.json +1 -1
  7. package/ui/css/theme-default/styles/base.css +86 -21
  8. package/ui/css/theme-default/styles/components/api-doc.css +6 -3
  9. package/ui/css/theme-default/styles/components/content.css +140 -39
  10. package/ui/css/theme-default/styles/components/custom-block.css +1 -1
  11. package/ui/css/theme-default/styles/components/htl-doc.css +243 -64
  12. package/ui/css/theme-default/styles/components/htl-library.css +152 -0
  13. package/ui/css/theme-default/styles/components/htl-search.css +267 -0
  14. package/ui/css/theme-default/styles/components/sidebar.css +59 -16
  15. package/ui/css/theme-default/styles/fonts.css +17 -3
  16. package/ui/css/theme-default/styles/htldoc.layouts.css +756 -87
  17. package/ui/css/theme-default/styles/vars.css +40 -35
  18. package/ui/favicon.svg +64 -0
  19. package/ui/images/hornbill-logo-full-reversed.svg +92 -0
  20. package/ui/images/hornbill-logo-full.svg +92 -0
  21. package/ui/images/hug_library.jpg +0 -0
  22. package/ui/images/mcp-catalog.svg +9 -0
  23. package/ui/images/products/hornbill-square.svg +1 -0
  24. package/ui/index.html +1106 -342
  25. package/ui/js/bootstrap.js +89 -0
  26. package/ui/js/doc.hornbill.js +3156 -751
  27. package/ui/js/hb.vue.js +121 -0
  28. package/ui/js/highlightjs/highlight.pack.js +1513 -2
  29. package/ui/js/highlightjs/styles/vs2015-accessible.css +141 -0
  30. package/ui/js/highlightjs-badge.js +41 -58
  31. package/ui/js/mermaid.min.js +1447 -1295
  32. package/ui/js/webcomponents/hdocApprove.js +115 -0
  33. package/ui/js/highlightjs/styles/brown-paper.css +0 -64
  34. package/ui/js/highlightjs/styles/brown-papersq.png +0 -0
  35. package/ui/js/highlightjs/styles/codepen-embed.css +0 -60
  36. package/ui/js/highlightjs/styles/color-brewer.css +0 -71
  37. package/ui/js/highlightjs/styles/darcula.css +0 -77
  38. package/ui/js/highlightjs/styles/dark.css +0 -63
  39. package/ui/js/highlightjs/styles/darkula.css +0 -6
  40. package/ui/js/highlightjs/styles/default.css +0 -99
  41. package/ui/js/highlightjs/styles/dracula.css +0 -76
  42. package/ui/js/highlightjs/styles/far.css +0 -71
  43. package/ui/js/highlightjs/styles/foundation.css +0 -88
  44. package/ui/js/highlightjs/styles/github-gist.css +0 -71
  45. package/ui/js/highlightjs/styles/github-mm.css +0 -71
  46. package/ui/js/highlightjs/styles/github.css +0 -99
  47. package/ui/js/highlightjs/styles/googlecode.css +0 -89
  48. package/ui/js/highlightjs/styles/grayscale.css +0 -101
  49. package/ui/js/highlightjs/styles/idea.css +0 -97
  50. package/ui/js/highlightjs/styles/ir-black.css +0 -73
  51. package/ui/js/highlightjs/styles/kavadocs.css +0 -71
  52. package/ui/js/highlightjs/styles/kavadocsdark.css +0 -120
  53. package/ui/js/highlightjs/styles/kimbie.dark.css +0 -74
  54. package/ui/js/highlightjs/styles/kimbie.light.css +0 -74
  55. package/ui/js/highlightjs/styles/magula.css +0 -70
  56. package/ui/js/highlightjs/styles/mono-blue.css +0 -59
  57. package/ui/js/highlightjs/styles/monokai-sublime.css +0 -83
  58. package/ui/js/highlightjs/styles/monokai.css +0 -70
  59. package/ui/js/highlightjs/styles/obsidian.css +0 -88
  60. package/ui/js/highlightjs/styles/paraiso-dark.css +0 -72
  61. package/ui/js/highlightjs/styles/paraiso-light.css +0 -72
  62. package/ui/js/highlightjs/styles/railscasts.css +0 -106
  63. package/ui/js/highlightjs/styles/rainbow.css +0 -85
  64. package/ui/js/highlightjs/styles/solarized-dark.css +0 -84
  65. package/ui/js/highlightjs/styles/solarized-light.css +0 -84
  66. package/ui/js/highlightjs/styles/sunburst.css +0 -102
  67. package/ui/js/highlightjs/styles/twilight.css +0 -97
  68. package/ui/js/highlightjs/styles/vs.css +0 -68
  69. package/ui/js/highlightjs/styles/vs2015.css +0 -117
  70. package/ui/js/highlightjs/styles/xcode.css +0 -104
  71. package/ui/js/highlightjs/styles/zenburn.css +0 -80
@@ -2,808 +2,3213 @@
2
2
  //-- THIS CONTROLS NAVIGATION
3
3
  //-- loaded in index.html
4
4
 
5
- let jqContentContainer = null;
6
- let jqDocumentHeader = null;
7
- let jqLeftNav = null;
8
- const global = { stateParams: {}, lastLayoutClass: "" };
5
+ var bInitialPageLoad = true;
6
+ var global = {stateParams:{},session:{}};
7
+ var arrSearchResponseTimes = {};
8
+ var arrSearchResponseTimesByWordCount = {};
9
+
10
+ //-- Search request tracking. The debounce in searchDocuments only cancels a pending
11
+ //-- TIMER - once a fetch is away it runs to completion, so two overlapping searches
12
+ //-- resolved in arrival order and a slow earlier query could land last and clobber
13
+ //-- the newer results. searchRequestSeq is a monotonic id: the .then compares its
14
+ //-- captured id against the current one and drops the response if it has been
15
+ //-- superseded. searchAbortController additionally cancels the older request so we
16
+ //-- aren't paying for a response we would only throw away. //-- SG
17
+ var searchRequestSeq = 0;
18
+ var searchAbortController = null;
19
+
20
+ var docAppMethods = {
21
+ setSessionInfo:function(info)
22
+ {
23
+ let self = this;
24
+ self.docApp.userSession = info;
25
+ global.session = info;
26
+ },
27
+
28
+ switchSettingsView:function()
29
+ {
30
+ // Store current page so we can return to it
31
+ sessionStorage.setItem("hdoc-settings-return-url", document.location.href);
32
+ const currUrl = new URL(document.location.href);
33
+ document.location.href = currUrl.origin + "/settings";
34
+ },
35
+ switchViewTheme:function()
36
+ {
37
+ if(ThemePreference)document.documentElement.classList.remove(ThemePreference);
38
+
39
+ if(ThemePreference === 'dark')
40
+ {
41
+ ThemePreference = 'light';
42
+ }
43
+ else{
44
+ ThemePreference = 'dark';
45
+ }
46
+
47
+ localStorage.setItem('hdocbook-theme-appearance',ThemePreference);
48
+ document.documentElement.classList.add(ThemePreference);
49
+
50
+ //-- so can show different theme button icon need to store pref against view app
51
+ //-- ThemePreference is declared in data(), so this assignment re-renders the two theme
52
+ //-- buttons on its own - no $forceUpdate() needed. //-- SG
53
+ let self = this;
54
+ self.docApp.ThemePreference = ThemePreference;
55
+
56
+ },
57
+ renderNavigation:function()
58
+ {
59
+ let self = this;
60
+
61
+ //-- Seed expand:false on every node before handing the tree to Vue. hdocbook.json has no
62
+ //-- expand key, so it was previously created on first click. Vue 3 tracks late-added
63
+ //-- properties so that worked, but declaring it up front means the collapse state is part
64
+ //-- of the shape Vue observes from the outset rather than appearing mid-flight - which is
65
+ //-- what lets toggleNavCollapse() drop its $forceUpdate(). //-- SG
66
+ let seedExpand = function(items)
67
+ {
68
+ if(!Array.isArray(items)) return;
69
+ for(let x=0;x<items.length;x++)
70
+ {
71
+ if(items[x].expand === undefined) items[x].expand = false;
72
+ seedExpand(items[x].items);
73
+ }
74
+ };
75
+ seedExpand(self.docApp.book.navigation.items);
76
+
77
+ self.docApp.navSections = self.docApp.book.navigation.items; //-- show be an array of nav groups
78
+ },
79
+ toggleTocMenu:function()
80
+ {
81
+ view.docApp.tocMobileOpen = !view.docApp.tocMobileOpen;
82
+ },
83
+ closeTocMenu:function()
84
+ {
85
+ view.docApp.tocMobileOpen = false;
86
+ },
87
+ toggleMobileMenu:function()
88
+ {
89
+ view.docApp.bookNavOpen = !view.docApp.bookNavOpen;
90
+ },
91
+ resetMobileMenu:function(ev)
92
+ {
93
+ //-- Runs on EVERY click anywhere in the app, via data-root-click on the mount host.
94
+ //--
95
+ //-- Both panels it governs are Vue-rendered, so their open state is Vue state and this
96
+ //-- function only ever writes that state - never a class. It used to toggle .open on
97
+ //-- .DocSidebar directly while keeping a second copy of the flag in
98
+ //-- $(".mobile-menu-btn").data("isopen"): two sources of truth over DOM that Vue
99
+ //-- rewrites on re-render. That is exactly what made the search Filters button look
100
+ //-- dead - the class was stripped here on the same click that opened it, while Vue
101
+ //-- still believed the panel was open and so never re-added it. //-- SG
102
+ if(!ev || !ev.target || !ev.target.closest) return;
103
+
104
+ let target = ev.target;
105
+
106
+ //-- a click on a content link (a.DocLink) should dismiss the menu - the user has picked a
107
+ //-- page. Group expand/collapse rows are .navSectionGroupItem divs, not .DocLink anchors,
108
+ //-- so they still fall through to the keep-open clause below. //-- SG
109
+ //--
110
+ //-- closest() matches the element itself as well as its ancestors, which is why the old
111
+ //-- hasClass(...) || closest(...) pairs collapse to one test each. //-- SG
112
+ let clickedNavLink = !!target.closest("a.DocLink");
113
+ let keepBookNavOpen = view.docApp.bookNavOpen && !clickedNavLink && !!target.closest(".hdoc-nav");
114
+
115
+ if(!(keepBookNavOpen || target.closest(".mobile-menu-btn") || target.closest(".navSectionGroupItem")))
116
+ {
117
+ view.docApp.bookNavOpen = false;
118
+ }
119
+
120
+ //-- the search filter panel dismisses on an outside click too. Its own button is excluded
121
+ //-- or it would close on the very click that opened it. //-- SG
122
+ if(view.docApp.searchFiltersOpen
123
+ && !target.closest(".LibrarySearch .DocSidebar")
124
+ && !target.closest(".search-filter-btn"))
125
+ {
126
+ view.docApp.searchFiltersOpen = false;
127
+ }
128
+ },
129
+ organiseLibraryByGroup:function(oGroupMode,registerUrlState)
130
+ {
131
+ //-- coming from event - so want to change url state so can click back to last lib view mode
132
+ if(registerUrlState)
133
+ {
134
+ //-- set url to root
135
+ let urlToSet = window.location.origin + "/?mode=" + oGroupMode.mode;
136
+ const url = new URL(urlToSet);
137
+ window.history.pushState(null, null, url);
138
+ }
139
+ organiseLibrary(oGroupMode);
140
+ },
141
+ loadProductHome:function(productGroup,registerUrlState)
142
+ {
143
+ //-- coming from event - so want to change url state so can click back to last lib view mode
144
+ if(registerUrlState)
145
+ {
146
+ //-- set url to root
147
+ let urlToSet = window.location.origin + "/?mode=product&product="+productGroup.id;
148
+ const url = new URL(urlToSet);
149
+ window.history.pushState(null, null, url);
150
+ }
151
+ view.docApp.libraryHomeViewProduct = productGroup;
152
+
153
+ },
154
+ loadLibraryHome:function()
155
+ {
156
+ //-- will reset view
157
+ view.docApp.boolSearchInProgress = false;
158
+ view.docApp.searchMode = false;
159
+ view.docApp.mcpCatalogMode = false;
160
+ view.docApp.searchResponse = null;
161
+ view.docApp.searchFilterBySpecificBookId = "";
162
+
163
+ resetCheckedProducts();
164
+ view.docApp.searchFilteredProducts = {};
165
+
166
+ view.docApp.altSearchText = "";
167
+ view.docApp.searchText = "";
168
+ view.docApp.book = {};
169
+ view.docApp.libraryHomeViewProduct = null;
170
+
171
+ document.title = "Hornbill Documentation";
172
+
173
+ //-- set url to root
174
+ let urlToSet = window.location.origin + "/";
175
+ const url = new URL(urlToSet);
176
+ window.history.pushState(null, null, url);
177
+
178
+ //-- remove hidden class
179
+ revealApp();
180
+
181
+ },
182
+ clearSearchFilters:function($ev)
183
+ {
184
+
185
+
186
+ resetCheckedProducts();
187
+ view.docApp.searchFilteredProducts = {};
188
+ view.docApp.hasFilterByProducts = false;
189
+ view.docApp.searchFilterBySpecificBookId = "";
190
+ view.docApp.searchFilterBySpecificBookTitle = "";
191
+ view.docApp.searchAudienceFilter = "";
192
+ view.docApp.searchDocTypeFilter = "";
193
+ //-- clearing the filters is an explicit choice of "no content-type filter", so it
194
+ //-- persists like any other pick - otherwise the remembered value would silently
195
+ //-- come back on the next auto-widen restore //-- SG
196
+ rememberDocTypeFilter("");
197
+ view.searchDocuments(view.docApp.altSearchText,false,$ev,true);
198
+
199
+ },
200
+ filterSearchByAudience:function($ev)
201
+ {
202
+ //-- v-model has already written the selected value to searchAudienceFilter;
203
+ //-- this is a server-side filter so re-run the search //-- SG
204
+ view.searchDocuments(view.docApp.altSearchText,false,$ev,true);
205
+ },
206
+ toggleSearchFilters:function()
207
+ {
208
+ //-- Below 1020px the filter sidebar is hidden and this view has no burger, so
209
+ //-- the filters were unreachable. The panel is already styled to slide in via
210
+ //-- .DocSidebar.open; this just drives that class.
211
+ //--
212
+ //-- Bound through Vue rather than jQuery like the book-page burger: this state
213
+ //-- is reactive, so a jQuery-applied class is patched straight back off on the
214
+ //-- re-render that setting it triggers. The book nav gets away with jQuery only
215
+ //-- because nothing re-renders that element. //-- SG
216
+ view.docApp.searchFiltersOpen = !view.docApp.searchFiltersOpen;
217
+ },
218
+ closeSearchFilters:function()
219
+ {
220
+ view.docApp.searchFiltersOpen = false;
221
+ },
222
+ filterSearchByDocType:function(docType,$ev)
223
+ {
224
+ //-- server-side soft filter; re-run so the counts come back whole-corpus //-- SG
225
+ view.docApp.searchDocTypeFilter = docType;
226
+ rememberDocTypeFilter(docType);
227
+ view.docApp.searchDocTypeRescue = null;
228
+ docTypeAutoWidenedForQuery = null;
229
+ view.closeSearchFilters();
230
+ view.searchDocuments(view.docApp.altSearchText,false,$ev,true);
231
+ },
232
+ filterSearchByProducts:function(product,$ev)
233
+ {
234
+ let productId = product.id;
235
+ if(view.docApp.searchFilteredProducts[productId])
236
+ {
237
+ view.docApp.searchFilteredProducts[productId].checked = !view.docApp.searchFilteredProducts[productId].checked;
238
+ }
239
+ else
240
+ {
241
+
242
+ view.docApp.searchFilteredProducts[productId] = {checked:true};
243
+ }
244
+
245
+ //-- so check or uncheck the flag in ui
246
+ product.checked = view.docApp.searchFilteredProducts[productId].checked;
247
+
248
+ //-- client side filtering
249
+ if(view.docApp.searchResponse && Array.isArray(view.docApp.searchResponse.results))
250
+ {
251
+ prepareSearchResultItems(view.docApp.searchResponse.results || []);
252
+ }
253
+
254
+ let checkedProducts = getCheckedProductFilterArray();
255
+ view.docApp.hasFilterByProducts = checkedProducts.length > 0; //-- declared boolean, keep it one
256
+
257
+
258
+
259
+ },
260
+ filterSearchByBook:function(aBook,$ev)
261
+ {
262
+ view.docApp.searchFilterBySpecificBookId = aBook.bookId;
263
+ view.docApp.searchFilterBySpecificBookTitle = aBook.title;
264
+ //-- an explicit pick replaces whatever bad id the rescue reported on
265
+ view.docApp.searchBookRescue = null;
266
+ //-- going to re-search
267
+ view.searchDocuments(view.docApp.altSearchText,false,$ev,true);
268
+
269
+ },
270
+ toolbarSearch:function(strOnLoadSearch,initalBookIdFilter,boolFromPop)
271
+ {
272
+ //-- "|| \"\"": index.html binds keyup.enter="toolbarSearch()" with no arguments, so an
273
+ //-- empty toolbar box used to hand undefined through to searchDocuments, which threw on
274
+ //-- .match() before it could take its own empty-search exit //-- SG
275
+ if(!view.docApp.searchText)view.docApp.searchText = strOnLoadSearch || "";
276
+ view.docApp.altSearchText = view.docApp.searchText;
277
+ view.docApp.currentResultsSearchText = "";
278
+ view.docApp.searchFilterBySpecificBookId = initalBookIdFilter || "";
279
+ view.searchDocuments(view.docApp.searchText,boolFromPop);
280
+
281
+ },
282
+
283
+ openMcpCatalogView:function()
284
+ {
285
+ //-- /mcp - the catalog of MCP servers this site exposes. Same shape as
286
+ //-- openSearchView: clear the other views, push the url, then load. The data
287
+ //-- comes from _api/mcp_catalog, which is generated from the servers actually
288
+ //-- mounted rather than a hand-kept list. //-- SG
289
+ view.docApp.book = {};
290
+ view.docApp.searchMode = false;
291
+ view.docApp.libraryHomeViewProduct = null;
292
+ view.docApp.mcpCatalogMode = true;
293
+ document.title = "Connect an AI assistant - Hornbill Documentation";
294
+
295
+ if(window.location.pathname !== "/mcp")
296
+ {
297
+ window.history.pushState({}, "", "/mcp");
298
+ }
299
+
300
+ revealApp();
301
+ view.loadMcpCatalog();
302
+ },
303
+
304
+ loadMcpCatalog:function()
305
+ {
306
+ //-- static enough to fetch once per page load
307
+ if(view.docApp.mcpCatalog.loaded) return;
308
+
309
+ fetchJsonFile("_api/mcp_catalog").then(function(data)
310
+ {
311
+ if(data && data.servers)
312
+ {
313
+ view.docApp.mcpCatalog.servers = data.servers;
314
+ view.docApp.mcpCatalog.loaded = true;
315
+ view.docApp.mcpCatalog.error = false;
316
+ }
317
+ else
318
+ {
319
+ //-- keep the model shape and flag the error; the template dereferences
320
+ //-- mcpCatalog.* at render time, so nulling it would be a blank view //-- SG
321
+ view.docApp.mcpCatalog.error = true;
322
+ }
323
+ });
324
+ },
325
+
326
+ mcpToolDescriptionParts:function(text)
327
+ {
328
+ //-- tool descriptions are written for the MODEL, so they mark identifiers with
329
+ //-- backticks. Split on them so the page can render real <code> spans without
330
+ //-- v-html - the descriptions are ours, but nothing here needs to compile HTML
331
+ //-- from data, and this site deliberately never does. //-- SG
332
+ return String(text || "").split("`").map(function(part, i)
333
+ {
334
+ return { text: part, code: (i % 2) === 1 };
335
+ });
336
+ },
337
+
338
+ mcpCopy:function(key, text)
339
+ {
340
+ //-- Copy a connection field. Feedback is a reactive flag per field rather than
341
+ //-- DOM poking, so the icon swap is just a binding. navigator.clipboard needs a
342
+ //-- secure context; the docs sites are https and localhost counts, but fall back
343
+ //-- to the execCommand approach the code-block badge uses so a plain-http
344
+ //-- deployment still works. //-- SG
345
+ var settle = function(ok)
346
+ {
347
+ view.docApp.mcpCopied[key] = ok;
348
+ setTimeout(function(){ delete view.docApp.mcpCopied[key]; }, 1600);
349
+ };
350
+
351
+ if(navigator.clipboard && window.isSecureContext)
352
+ {
353
+ navigator.clipboard.writeText(text).then(function(){ settle(true); }, function(){ settle(false); });
354
+ return;
355
+ }
356
+
357
+ var el = document.createElement("textarea");
358
+ el.value = text;
359
+ el.setAttribute("readonly", "");
360
+ el.style.position = "absolute";
361
+ el.style.left = "-9999px";
362
+ document.body.appendChild(el);
363
+ el.select();
364
+ var ok = false;
365
+ try { ok = document.execCommand("copy"); } catch(e) { ok = false; }
366
+ document.body.removeChild(el);
367
+ settle(ok);
368
+ },
369
+
370
+ mcpServerUrl:function(path)
371
+ {
372
+ return window.location.origin + path;
373
+ },
374
+
375
+ openSearchView:function()
376
+ {
377
+ //-- narrow-viewport search button: below 602px the inline toolbar search box
378
+ //-- (.toolbar-search-bar) is hidden, so this takes the user to the search page,
379
+ //-- which has the full-width banner search input. With no query yet this renders
380
+ //-- the same view as loading /search directly - the library home with the banner
381
+ //-- box - so refresh and back/forward behave consistently. Clearing book drops the
382
+ //-- document view; the back button restores it via popstate. //-- SG
383
+ view.docApp.book = {};
384
+ view.docApp.searchMode = false;
385
+ view.docApp.mcpCatalogMode = false;
386
+ view.docApp.searchText = "";
387
+ view.docApp.altSearchText = "";
388
+ view.docApp.currentResultsSearchText = "";
389
+ view.docApp.libraryHomeViewProduct = null;
390
+ document.title = "Hornbill Documentation Search";
391
+ if(window.location.pathname !== "/search")
392
+ {
393
+ window.history.pushState(null, null, window.location.origin + "/search");
394
+ }
395
+ //-- focus the banner search box once Vue has painted the library/search layout
396
+ //-- (setTimeout runs after Vue's microtask render flush)
397
+ setTimeout(function(){ let inp = document.querySelector(".library-banner-hug input"); if(inp) inp.focus(); },0);
398
+ },
399
+
400
+ searchDocuments:function(inAltSearchText,inBoolFromPopState,ev,inBoolFromBookFilter)
401
+ {
402
+ if(!inAltSearchText) inAltSearchText = ""; //-- callers can legitimately pass nothing
403
+ //-- kept for the length-gate exit below, which may have to put the view back the way
404
+ //-- it found it (the title is rewritten before the gate is reached) //-- SG
405
+ let titleBeforeSearch = document.title;
406
+ //-- note: despite the name, validSearch is TRUE when the quote count is odd, i.e. when
407
+ //-- the search is INVALID - index.html uses it to show the unbalanced-quotes warning
408
+ view.docApp.validSearch = ((inAltSearchText.match(/"/g) || []).length % 2 === 1);
409
+ let waitTime = 1;
410
+ let boolWasEnterKeyOrSearchButton = false;
411
+ if(ev)
412
+ {
413
+ ev.stopImmediatePropagation();
414
+ ev.preventDefault();
415
+ boolWasEnterKeyOrSearchButton = (ev.key==="Enter" || ev.type==="click"); //-- we will store url state on enter or search btn click
416
+ }
417
+
418
+ //-- clear any pending search
419
+ clearTimeout(view.searchTimeoutId);
420
+
421
+
422
+
423
+
424
+ //-- if there is a http search inprogress then store this one to do once response is returned
425
+ view.docApp.nextSearchCriteria = {};
426
+ view.docApp.nextSearchCriteria.altSearchText = inAltSearchText;
427
+ view.docApp.nextSearchCriteria.boolFromPopState = inBoolFromPopState;
428
+ view.docApp.nextSearchCriteria.eventInfo = ev;
429
+ view.docApp.nextSearchCriteria.boolWasEnterKeyOrSearchButton = boolWasEnterKeyOrSearchButton;
430
+
431
+ //-- do we have anything to search
432
+ view.docApp.searchMode = (inAltSearchText || view.docApp.searchText)?true:false;
433
+
434
+ if(!view.docApp.searchMode)
435
+ {
436
+ document.title = "Hornbill Documentation";
437
+ //-- set url home index
438
+ view.docApp.currentResultsSearchText = "";
439
+ view.docApp.searchTooShort = false;
440
+ hideSearchTooShortHint();
441
+
442
+ //-- set url to root (only if the current url is a search url)
443
+ if(window.location.href.indexOf("?q=") !== -1)
444
+ {
445
+ let urlToSet = window.location.origin + "/";
446
+ const url = new URL(urlToSet);
447
+ window.history.replaceState(null, null, url);
448
+ }
449
+ return;
450
+ }
451
+ document.title = "Hornbill Documentation Search";
452
+
453
+ //-- single-character queries never fetch: the server drops unquoted single-char
454
+ //-- terms (they can only return noise), so the round-trip per keystroke would buy
455
+ //-- an empty result set. Raw length check on purpose - a QUOTED single char ("e")
456
+ //-- is three characters and goes through, matching the server's phrase exemption.
457
+ //-- boolSearchInProgress may be true from a just-cancelled pending search, so put
458
+ //-- it back down or the results list stays dimmed //-- SG
459
+ if(inAltSearchText.trim().length === 1)
460
+ {
461
+ view.docApp.boolSearchInProgress = false;
462
+ //-- Returning without touching the view left whatever was on screen standing, and
463
+ //-- the header names currentResultsSearchText, not what is in the box: typing "qq"
464
+ //-- then backspacing showed "0 results for: qq" for a one-character query, and a
465
+ //-- single character typed after any earlier search showed that search's hits as
466
+ //-- though they matched. A first character showed nothing at all - no results, no
467
+ //-- header, no reason. Clear the previous answer and say why there is none. //-- SG
468
+ //-- A document is open (the toolbar search box is on every book page): a single
469
+ //-- character has nothing to show, so leave the document alone rather than half
470
+ //-- opening the search over it. searchMode was set above, and the book view is
471
+ //-- rendered off book.docId - which the gate never reaches the code that clears -
472
+ //-- so both panels were on screen at once, the article squeezed into the corner.
473
+ //-- No notice here: the box still holds what was typed, and destroying what the
474
+ //-- user is reading to say "keep typing" is the worse trade. //-- SG
475
+ if(view.docApp.book && view.docApp.book.docId)
476
+ {
477
+ view.docApp.searchMode = false;
478
+ view.docApp.searchTooShort = false;
479
+ //-- nothing on screen changes on this path, so pressing Enter looked like a dead
480
+ //-- key. Say why, next to the box that was typed in, and take it away again on its
481
+ //-- own - a hint the user has to dismiss is worse than the silence it replaces //-- SG
482
+ showSearchTooShortHint();
483
+ document.title = titleBeforeSearch;
484
+ return;
485
+ }
486
+
487
+ view.docApp.searchTooShort = true;
488
+ view.docApp.searchResponse = null;
489
+ view.docApp.searchResultFilteredItems = [];
490
+ view.docApp.currentResultsSearchText = "";
491
+ view.docApp.searchError = "";
492
+ view.docApp.searchDocTypeRescue = null;
493
+ view.docApp.searchBookRescue = null;
494
+ docTypeAutoWidenedForQuery = null;
495
+ resetFilteredProductsListCounters();
496
+ //-- the url described the results we just dropped, so it must not keep claiming them
497
+ if(window.location.href.indexOf("?q=") !== -1)
498
+ {
499
+ window.history.replaceState(null, null, window.location.origin + "/search");
500
+ }
501
+ return;
502
+ }
503
+
504
+ //-- past the length gate, so any "too short" notice belongs to a previous keystroke
505
+ view.docApp.searchTooShort = false;
506
+ hideSearchTooShortHint();
507
+
508
+ //-- searching same thing so leave
509
+ if(inAltSearchText===view.docApp.currentResultsSearchText && !boolWasEnterKeyOrSearchButton && !inBoolFromPopState && !inBoolFromBookFilter) return;
510
+
511
+ //-- set a wait time. The debounce exists to let a typing user finish their word -
512
+ //-- filter changes (doc type / book / audience radios and selects, which arrive as
513
+ //-- "change" events, not "click") are a completed intent, so they'd only be slowed
514
+ //-- by it: the facet click already paid the full search round-trip, and the extra
515
+ //-- 250ms+ here was most of the perceived lag //-- SG
516
+ if(!boolWasEnterKeyOrSearchButton && !inBoolFromPopState && !inBoolFromBookFilter)
517
+ {
518
+ waitTime = calculateSearchWaitTimeBasedOnQueryText(inAltSearchText);
519
+ hslDocLog.log("SEARCH: SETTING FETCH DELAY: " + waitTime + "ms");
520
+ }
521
+
522
+ //-- Committed to the search view now, so drop the document here rather than inside the
523
+ //-- debounced callback below. The search panel renders off searchMode (set above) while
524
+ //-- the book panel renders off book.docId, so for the whole debounce + fetch the article
525
+ //-- was still mounted and shared the viewport with the search layout - the page visibly
526
+ //-- squeezed into the bottom-right corner until the results skeleton replaced it. The
527
+ //-- skeleton needs boolSearchInProgress, set immediately below, so the swap is one frame
528
+ //-- with nothing in between. //-- SG
529
+ if(view.docApp.book && view.docApp.book.docId) view.docApp.book = {};
530
+
531
+ view.docApp.boolSearchInProgress = true;
532
+ view.searchTimeoutId = setTimeout(function(altSearchText,boolFromPopState,boolWasEnterKeyOrSearchButton,srcEvent)
533
+ {
534
+ view.docApp.searchError = "";
535
+
536
+ resetFilteredProductsListCounters();
537
+
538
+ if(view.docApp.nextSearchCriteria)
539
+ {
540
+ altSearchText = view.docApp.nextSearchCriteria.altSearchText;
541
+ boolFromPopState =view.docApp.nextSearchCriteria.boolFromPopState;
542
+ boolWasEnterKeyOrSearchButton =view.docApp.nextSearchCriteria.boolWasEnterKeyOrSearchButton;
543
+ srcEvent =view.docApp.nextSearchCriteria.eventInfo;
544
+
545
+ //-- log the fields, not a deep copy of the criteria object. copyObject is a
546
+ //-- JSON.parse(JSON.stringify()) round-trip and nextSearchCriteria holds eventInfo,
547
+ //-- a jQuery event - so this ran a full serialise per keystroke for output that is
548
+ //-- only read if someone calls hslDocLog.dump(), and it was one property away from
549
+ //-- throwing on a circular structure (target / originalEvent) inside the search
550
+ //-- callback, which would have aborted the search. //-- SG
551
+ hslDocLog.log("SEARCH: NEW CRITERIA DETECTED: There is new search criteria waiting before current http search request takes place....using new criteria",
552
+ {altSearchText:altSearchText,boolFromPopState:boolFromPopState,boolWasEnterKeyOrSearchButton:boolWasEnterKeyOrSearchButton})
553
+
554
+ view.docApp.nextSearchCriteria = null;
555
+ }
556
+
557
+
558
+ //-- user has typed in search
559
+ if(altSearchText)
560
+ {
561
+ //-- only search if search is different to current one
562
+ if(altSearchText===view.docApp.currentResultsSearchText && !boolWasEnterKeyOrSearchButton && !inBoolFromBookFilter)return;
563
+ }
564
+ else
565
+ {
566
+ //-- we did search on the main landing page - so clear search results and filters
567
+ view.docApp.searchResultFilteredItems = [];
568
+ view.docApp.searchResponse = null;
569
+ view.docApp.searchFilterBySpecificBookId = "";
570
+
571
+ //-- if search is empty then do nothing
572
+ if(!view.docApp.searchText)
573
+ {
574
+ view.docApp.searchMode = false;
575
+ view.docApp.mcpCatalogMode = false;
576
+ view.docApp.currentResultsSearchText = "";
577
+ view.docApp.boolSearchInProgress = false;
578
+ return;
579
+ }
580
+ }
581
+
582
+ view.docApp.book = {};
583
+
584
+ //-- set url to search/or update search term
585
+ let urlToSet = window.location.origin + "/search";
586
+ let searchText = (altSearchText)?altSearchText:view.docApp.searchText;
587
+
588
+ //-- clear small search box
589
+ view.docApp.searchText = "";
590
+
591
+ //-- so can show in # result for {{docApp.currentResultsSearchText}}
592
+ view.docApp.currentResultsSearchText = searchText;
593
+
594
+ //-- NOTE: the previous results are deliberately NOT cleared here. Emptying the array
595
+ //-- at request time unmounted the whole results list (its v-if tests length), so every
596
+ //-- query destroyed and rebuilt all 300 rows even when the new results were nearly the
597
+ //-- same. The list now stays mounted and dimmed until the response replaces it in one
598
+ //-- assignment; the header switches to "Searching for:" so nothing claims the old count
599
+ //-- belongs to the new query. Cleared on the no-search and error paths instead. //-- SG
600
+ if(searchText)
601
+ {
602
+ //-- include bid if one is selected, and the audience filter when the
603
+ //-- (internal-only) picker has a value //-- SG
604
+ //-- restore the default once the query moves on from the one that was
605
+ //-- auto-widened, so the content-type filter keeps working for the next
606
+ //-- search instead of staying open because one query needed everything
607
+ if(docTypeAutoWidenedForQuery !== null && searchText !== docTypeAutoWidenedForQuery)
608
+ {
609
+ view.docApp.searchDocTypeFilter = docTypeUserDefaultFilter();
610
+ docTypeAutoWidenedForQuery = null;
611
+ }
612
+
613
+ let searchterm=encodeQueryData(buildSearchQueryParams(searchText));
614
+ urlToSet += "?" + searchterm;
615
+
616
+ hslDocLog.log("SEARCH: TRIGGER: perform search on ", urlToSet)
617
+
618
+ //-- we will keep trak of last response time for a search of this many words and length
619
+ let searchInfoKey = "s_w" + searchText.split(" ").length + "_l" + searchText.length;
620
+ arrSearchResponseTimes[searchInfoKey] = Date.now();
621
+
622
+ //-- supersede any request still in flight, then claim this id
623
+ if(searchAbortController) searchAbortController.abort();
624
+ searchAbortController = new AbortController();
625
+ let requestSeq = ++searchRequestSeq;
626
+
627
+ fetchJsonFile("_search?"+searchterm,{signal:searchAbortController.signal}).then(function(data)
628
+ {
629
+ //-- a newer search has been issued since this one left - drop the response
630
+ //-- rather than letting it overwrite fresher results. Deliberately does NOT
631
+ //-- touch boolSearchInProgress: the newer request still owns that. //-- SG
632
+ if(requestSeq !== searchRequestSeq)
633
+ {
634
+ hslDocLog.log("SEARCH: RESPONSE: discarded stale response (req " + requestSeq + " of " + searchRequestSeq + ")");
635
+ return;
636
+ }
637
+
638
+ //-- No body, or one fetchJsonFile could not parse - it resolves null for a bad
639
+ //-- JSON body rather than rejecting (item 20), so this lands in the SUCCESS
640
+ //-- handler. Without this guard `data.query` threw here, and a throw inside
641
+ //-- onFulfilled is not caught by the onRejected passed to the same .then(), so
642
+ //-- the failure was silent and boolSearchInProgress stuck true - the spinner
643
+ //-- never cleared and no error was shown. Same class as item 19. //-- SG
644
+ if(!data)
645
+ {
646
+ hslDocLog.log("SEARCH: RESPONSE: no usable response body");
647
+ view.docApp.searchResultFilteredItems = [];
648
+ view.docApp.boolSearchInProgress = false;
649
+ view.docApp.nextSearchCriteria = null;
650
+ view.docApp.searchError = "Failed to search for books. The server search response is invalid";
651
+ revealApp();
652
+ return;
653
+ }
654
+
655
+ hslDocLog.log("SEARCH: RESPONSE: completed",data)
656
+ if(data.query)
657
+ {
658
+ //-- telemetry only, and it picks apart the server's echoed query string. Any
659
+ //-- change to that shape made [1] undefined and threw here, aborting the rest
660
+ //-- of the handler - results never rendered and boolSearchInProgress stuck
661
+ //-- true. Guarded so it can never take the render down with it. //-- SG
662
+ try
663
+ {
664
+ let searchedText = data.query.split("MATCH '")[1].split("*' ORDER")[0];
665
+ let searchTextKey = "s_w" + searchedText.split(" ").length + "_l" + searchedText.length;
666
+ if(arrSearchResponseTimes[searchTextKey])
667
+ {
668
+ //-- store the overrall ms taken (also for word count)
669
+ arrSearchResponseTimes[searchTextKey] = Date.now() - arrSearchResponseTimes[searchTextKey];
670
+ arrSearchResponseTimesByWordCount[searchTextKey.split("_l")[0]] = arrSearchResponseTimes[searchTextKey];
671
+ hslDocLog.log("SEARCH: RESPONSE: total UI http response time " + arrSearchResponseTimes[searchTextKey] + "ms");
672
+ }
673
+ }
674
+ catch(e)
675
+ {
676
+ hslDocLog.log("SEARCH: RESPONSE: could not parse echoed query for timing telemetry",data.query);
677
+ }
678
+ }
679
+
680
+ view.docApp.boolSearchInProgress=false;
681
+ view.docApp.nextSearchCriteria = null;
682
+ view.docApp.searchResponse = data;
683
+
684
+ //-- an unknown bid in the url must not dead-end the search with an
685
+ //-- unexplained empty page. See evaluateBookFilterRescue //-- SG
686
+ if(evaluateBookFilterRescue())
687
+ {
688
+ view.searchDocuments(view.docApp.altSearchText,false,null,true);
689
+ return;
690
+ }
691
+
692
+ //-- the content-type filter defaults ON, so it must never turn a
693
+ //-- findable answer into an empty page. See evaluateDocTypeRescue //-- SG
694
+ if(evaluateDocTypeRescue())
695
+ {
696
+ view.searchDocuments(view.docApp.altSearchText,false,null,true);
697
+ return;
698
+ }
699
+
700
+ var resultsFoundinCurrentFilteredBook = false;
701
+ if(view.docApp.searchResponse.booksInfo && view.docApp.searchResponse.booksInfo.length)
702
+ {
703
+ //-- associate book title to each searched book
704
+ for(let x=0;x<view.docApp.searchResponse.booksInfo.length;x++)
705
+ {
706
+ let book_info = getBookById(view.docApp.searchResponse.booksInfo[x].bookId);
707
+
708
+ if(book_info !== undefined && book_info.title !== undefined)
709
+ {
710
+ view.docApp.searchResponse.booksInfo[x].title = book_info.title;
711
+ if(view.docApp.searchFilterBySpecificBookId && view.docApp.searchFilterBySpecificBookId===book_info.docId)
712
+ {
713
+ resultsFoundinCurrentFilteredBook = true;
714
+ view.docApp.searchFilterBySpecificBookTitle = book_info.title; //-- set disaply title for filtered results by book
715
+ //-- for when user presses f5 - browser refresh for any reason
716
+ localStorage.setItem('hdocbook-last-filterbooktitle', book_info.title);
717
+ localStorage.setItem('hdocbook-last-filterbookid', book_info.docId);
718
+ }
719
+ }
720
+ }
721
+
722
+ //-- alphabetical by display title for the Books filter list (server order
723
+ //-- is relevance-ish and reads as random)
724
+ sortBooksInfoByTitle();
725
+ }
726
+
727
+ if(view.docApp.searchResponse.results)
728
+ {
729
+ if(!resultsFoundinCurrentFilteredBook && view.docApp.searchFilterBySpecificBookId)
730
+ {
731
+ //-- the selected book has no results for this query, so it is absent from
732
+ //-- booksInfo - show it as a selected zero-count entry, or the Books list
733
+ //-- renders with NOTHING selected (not even All) and the user cannot tell
734
+ //-- a book filter is active. Title resolution: the title stored when the
735
+ //-- user picked the book, else the library (valid book, no matches), else
736
+ //-- the raw bid - a pasted url with an unknown/mistyped bid shows the bad
737
+ //-- id itself as the selected filter, which is the diagnostic //-- SG
738
+ let dummyTitle = null;
739
+ if(view.docApp.searchFilterBySpecificBookId===localStorage.getItem('hdocbook-last-filterbookid'))
740
+ {
741
+ dummyTitle = localStorage.getItem('hdocbook-last-filterbooktitle');
742
+ }
743
+ if(!dummyTitle)
744
+ {
745
+ let book_info = getBookById(view.docApp.searchFilterBySpecificBookId);
746
+ dummyTitle = (book_info && book_info.title) ? book_info.title : view.docApp.searchFilterBySpecificBookId;
747
+ }
748
+
749
+ view.docApp.searchFilterBySpecificBookTitle = dummyTitle;
750
+
751
+ //-- a zero-result response may carry no booksInfo at all
752
+ if(!Array.isArray(view.docApp.searchResponse.booksInfo)) view.docApp.searchResponse.booksInfo = [];
753
+
754
+ let dummyBook = {bookId:view.docApp.searchFilterBySpecificBookId,title:dummyTitle,foundCount:0};
755
+ view.docApp.searchResponse.booksInfo.push(dummyBook);
756
+ //-- keep the list alphabetical with the dummy slotted in place
757
+ sortBooksInfoByTitle();
758
+ }
759
+ prepareSearchResultItems(view.docApp.searchResponse.results);
760
+ }
761
+
762
+
763
+ view.$nextTick(function()
764
+ {
765
+ //-- no rebinding needed: link clicks are handled by one delegated
766
+ //-- handler on document, so freshly rendered result links just work
767
+ revealApp();
768
+
769
+ setTimeout(function()
770
+ {
771
+ if(view.docApp.currentResultsSearchText && !view.docApp.boolSearchInProgress)
772
+ {
773
+ //-- getSearchUrlLink() builds the same q/bid url this used to
774
+ //-- construct inline (third copy of the same expression)
775
+ let newUrlToSet = getSearchUrlLink();
776
+ if( window.location.href!==newUrlToSet) window.history.pushState(null, null, newUrlToSet);
777
+ }
778
+ },1000);
779
+
780
+ });
781
+
782
+
783
+ },function(err)
784
+ {
785
+ //-- an abort is us superseding our own request, not a failure - and a stale
786
+ //-- rejection must not surface an error over newer results. //-- SG
787
+ if(err && err.name === "AbortError") return;
788
+ if(requestSeq !== searchRequestSeq) return;
789
+ //-- results are no longer cleared at request time, so drop the previous ones
790
+ //-- here rather than leaving them under an error message //-- SG
791
+ view.docApp.searchResultFilteredItems = [];
792
+ view.docApp.boolSearchInProgress = false;
793
+ view.docApp.searchError = "Failed to search for books. The server search response is invalid";
794
+ revealApp();
795
+ });
796
+ }
797
+ else
798
+ {
799
+ view.docApp.searchMode = false;
800
+ view.docApp.mcpCatalogMode = false;
801
+ view.docApp.boolSearchInProgress = false;
802
+
803
+ //-- show prompt to enter search criteria
804
+ view.$nextTick(function()
805
+ {
806
+ revealApp();
807
+ });
808
+ }
809
+
810
+ //-- update browser url
811
+ if(!boolFromPopState || boolWasEnterKeyOrSearchButton)
812
+ {
813
+ const url = new URL(urlToSet);
814
+ window.history.pushState(null, null, url);
815
+ }
816
+
817
+ },waitTime,inAltSearchText,inBoolFromPopState,boolWasEnterKeyOrSearchButton,ev); //-- passing in params
818
+ },
819
+ loadLibraryBook:function(aBook,fromPopState,ev)
820
+ {
821
+ view.docApp.boolSearchInProgress = false;
822
+ view.docApp.loadingBookInfo = true;
823
+ if(ev)
824
+ {
825
+ //-- if a url that is part of this doc site then handle loading content inline else let link do whatever (i.e. its an external link)
826
+ ev.stopImmediatePropagation();
827
+ ev.preventDefault();
828
+ }
829
+
830
+ // Get hdocbook.json
831
+ //-- hide the current document while the next one loads. Was $(".HTL-doc").addClass
832
+ //-- ("hb-hidden"), which wrote to Vue-owned elements and also caught the MCP catalog
833
+ //-- wrapper and the inline toolbar - both .HTL-doc, neither intended. //-- SG
834
+ view.docApp.contentLoading = true;
835
+
836
+
837
+ fetchJsonFile("_books/" + aBook.docId + "/hdocbook.json").then(function(data)
838
+ {
839
+ view.docApp.book = data;
840
+ view.docApp.title = data.title || "";
841
+ view.docApp.description = data.description || "";
842
+
843
+
844
+ view.docApp.loadingBookInfo = false; //-- move this here into timeout to avoid loading glitch that shows main landing page
845
+
846
+ //-- now render out the nav menu
847
+ view.renderNavigation();
848
+
849
+ //-- This was a hard-coded setTimeout(...,200) "wait for Vue to render the nav".
850
+ //-- renderNavigation() is synchronous, so $nextTick is the correct primitive: it
851
+ //-- fires as soon as Vue has flushed the DOM patch instead of guessing, which
852
+ //-- takes a flat 200ms off every book open. Kept as $nextTick rather than dropped
853
+ //-- so the nav DOM is in place before content loading starts. //-- SG
854
+ view.$nextTick(function()
855
+ {
856
+ //-- title band is now rendered - centre the floating TOC button against it
857
+ positionTocMobileBtn();
858
+
859
+ //-- check if url is not root - in which case we need to load content for url
860
+ if(fromPopState)
861
+ {
862
+ loadContentUrl(fromPopState,false,fromPopState);
863
+ }
864
+ else
865
+ {
866
+ const loadUrl = new URL(window.location.href);
867
+ var initialLoadContentHref = removeStartingSlash(loadUrl.pathname.replace(window.location.origin+"/",""));
868
+ //if(initialLoadContentHref==="")initialLoadContentHref = findFirstClickableBookLink(initialLoadContentHref);
869
+ let foundLinkHref = findFirstClickableBookLink(initialLoadContentHref);
870
+ if(foundLinkHref)
871
+ {
872
+ //-- find the link we are going to show and make sure parent/s are set to .expand=true
873
+ loadContentUrl(foundLinkHref + loadUrl.hash,false,fromPopState);
874
+ }
875
+ else
876
+ {
877
+ hslDocLog.log("could not find matching link or parent link in navigation menu");
878
+ loadContentUrl(initialLoadContentHref + loadUrl.hash,false,fromPopState);
879
+ }
880
+ }
881
+ });
882
+ });
883
+ }
884
+ };
885
+
886
+
887
+ //--
888
+ //-- user clicked back or forwards
889
+ var currentPopState = null;
890
+ window.addEventListener('popstate', function (event)
891
+ {
892
+ const loadUrl = new URL(event.target.location.href);
893
+ //loadContentUrl(loadUrl.pathname + loadUrl.hash,{},false,true);
894
+ currentPopState = event;
895
+ loadLibraryOrBookFromCurrentUrl(loadUrl.pathname + loadUrl.hash)
896
+ });
897
+
898
+
899
+ //--- GLOBAL FUNCTIONS
9
900
 
10
- //-- Mermaid theme lives in js/mermaid-theme.json the SINGLE source shared
11
- //-- with the server-side PDF SVG bake (hdoc build reads the same file). Fetch
12
- //-- failure falls back to the plain base theme rather than blocking rendering.
13
- function hbFetchMermaidTheme() {
14
- return fetch("js/mermaid-theme.json") //-- resolves against <base href>
15
- .then((r) => (r.ok ? r.json() : null))
16
- .catch(() => null);
901
+ //-- vertically centre the floating in-page TOC button (.toc-mobile-btn) against the book title
902
+ //-- band. The button is position:fixed inside .DocContent, so CSS alone cannot centre it on a
903
+ //-- band whose height varies with viewport width and title wrapping - measure the band and
904
+ //-- publish the result as a CSS variable consumed by content.css (tablet range only; the <=602px
905
+ //-- rules dock the button bottom-right and ignore the variable). Called after book render and on
906
+ //-- window resize. 48 = the button's CSS height. //-- SG
907
+ function positionTocMobileBtn()
908
+ {
909
+ let band = document.querySelector(".title-band");
910
+ if(band && band.offsetHeight)
911
+ {
912
+ let bandRect = band.getBoundingClientRect();
913
+ let btnTop = Math.round(bandRect.top + ((bandRect.height - 48) / 2));
914
+ document.documentElement.style.setProperty("--toc-mobile-btn-top", btnTop + "px");
915
+ }
916
+ else
917
+ {
918
+ //-- no band (library view) - fall back to the stylesheet default
919
+ document.documentElement.style.removeProperty("--toc-mobile-btn-top");
920
+ }
17
921
  }
922
+ window.addEventListener("resize", positionTocMobileBtn);
923
+
924
+ //-- Minimum debounce between keystrokes and the search request going out. The adaptive
925
+ //-- times below learn from server response times, but never go below this: with hybrid
926
+ //-- (keyword + semantic) search each request costs the server an AI inference, so firing
927
+ //-- on nearly every keystroke wastes server work for results the next keystroke replaces.
928
+ //-- 250ms sits under the perceived-as-instant threshold while letting a steady typer
929
+ //-- finish the word. //-- SG
930
+ const SEARCH_DEBOUNCE_MIN_MS = 250;
931
+
932
+ function calculateSearchWaitTimeBasedOnQueryText(strSearchQuery)
933
+ {
934
+ let arrWords = strSearchQuery.split(" ");
935
+ //-- check length of each word
936
+ let waitTime = 0;
937
+
938
+ //-- do we have a last wait time for a similar search word length and text length?
939
+ let searchInfoKey = "s_w" + arrWords.length + "_l" + strSearchQuery.length;
940
+ let wordCountSearchKey = "s_w" + arrWords.length;
941
+ //-- do we have exact match (same word count and same number of chars?)
942
+ if(arrSearchResponseTimes[searchInfoKey] && arrSearchResponseTimes[searchInfoKey]<1000)
943
+ {
944
+ //-- lets just use this + 20ms, but never below the floor
945
+ return Math.max(arrSearchResponseTimes[searchInfoKey] + 20, SEARCH_DEBOUNCE_MIN_MS);
946
+ }
947
+ delete arrSearchResponseTimes[searchInfoKey];
948
+
949
+ if(arrSearchResponseTimesByWordCount[wordCountSearchKey] && arrWords.length>1 && arrSearchResponseTimesByWordCount[wordCountSearchKey]<1000)
950
+ {
951
+ return Math.max(arrSearchResponseTimesByWordCount[wordCountSearchKey] + 20, SEARCH_DEBOUNCE_MIN_MS);
952
+ }
953
+
954
+ delete arrSearchResponseTimesByWordCount[wordCountSearchKey];
955
+
956
+ //-- we have no reference to use
957
+ //-- set initial waittime based of search text length
958
+ switch(strSearchQuery.length) {
959
+ case 0:
960
+ case 1:
961
+ case 2:
962
+ waitTime = 400;
963
+ break;
964
+ case 3:
965
+ waitTime = 300;
966
+ break;
967
+ case 4:
968
+ waitTime = 200;
969
+ break;
970
+ case 5:
971
+ waitTime = 100;
972
+ break;
973
+ default:
974
+ waitTime = 50;
975
+ break;
976
+ }
977
+
978
+ //-- now divide by number of words, for example searching for "apple" takes longer than searching for "ap ple";
979
+ waitTime = waitTime / arrWords.length;
980
+ if(waitTime<SEARCH_DEBOUNCE_MIN_MS)waitTime=SEARCH_DEBOUNCE_MIN_MS;
981
+
982
+ return waitTime;
983
+
984
+ }
985
+
986
+ //-- copyObject() lived here. Its only caller was the per-keystroke search log (item 13), which
987
+ //-- now logs the three fields it wanted rather than deep-copying a jQuery event. //-- SG
988
+
989
+ function encodeQueryData(data) {
990
+ const ret = [];
991
+ for (let d in data)
992
+ ret.push(encodeURIComponent(d) + '=' + encodeURIComponent(data[d]));
993
+ return ret.join('&');
994
+ }
995
+ function removeTrailingSlash(str) {
996
+ return str.endsWith('/') ? str.slice(0, -1) : str;
997
+ }
998
+ function removeStartingSlash(str) {
999
+ return str.startsWith('/') ? str.slice(1, str.length) : str;
1000
+
1001
+ //if(linkRef.indexOf("/")===0)linkRef = linkRef.replace("/","");
1002
+ }
1003
+
1004
+ function urlGetParam(locationSearch,paramName)
1005
+ {
1006
+ let params = new URLSearchParams(locationSearch);
1007
+ return params.get(paramName);
1008
+ }
1009
+
1010
+ function toSeoUrl(url) {
1011
+
1012
+ return url.toString() // Convert to string
1013
+ .normalize('NFD') // Change diacritics
1014
+ .replace(/[\u0300-\u036f]/g,'') // Remove illegal characters
1015
+ .replace(/\s+/g,'-') // Change whitespace to dashes
1016
+ .toLowerCase() // Change to lowercase
1017
+ .replace(/&/g,'-and-') // Replace ampersand
1018
+ .replace(/[^a-z0-9\-]/g,'') // Remove anything that is not a letter, number or dash
1019
+ .replace(/-+/g,'-') // Remove duplicate dashes
1020
+ .replace(/^-*/,'') // Remove starting dashes
1021
+ .replace(/-*$/,''); // Remove trailing dashes
1022
+ }
1023
+
1024
+
1025
+ function getMeta(metaName) {
1026
+ const metas = document.getElementsByTagName('meta');
1027
+
1028
+ for (let i = 0; i < metas.length; i++) {
1029
+ if (metas[i].getAttribute('name') === metaName) {
1030
+ return metas[i].getAttribute('content');
1031
+ }
1032
+ }
1033
+
1034
+ return '';
1035
+ }
1036
+
1037
+ function getAnchorFromHash(strHash,strHasClass)
1038
+ {
1039
+ try{
1040
+ let strSelector = toSeoUrl(strHash.replace("#",""));
1041
+ if (strSelector === '') return null;
1042
+ if(strHasClass)strSelector += "." + strHasClass;
1043
+
1044
+ //-- returns a DOM element (was a jQuery collection, so the caller unwrapped [0])
1045
+ return document.querySelector("#"+strSelector) || null;
1046
+ }
1047
+ catch(e)
1048
+ {
1049
+ hslDocLog.log("Get document anchor element by hash failed ["+strHash+"]",e);
1050
+ return null;
1051
+ }
1052
+
1053
+ }
1054
+
1055
+ //-- Resolve a hash fragment to a DOM element on the current page. Tolerant of headings that
1056
+ //-- were never assigned an id: generateTableOfContentsFromDoc() only gives ids to H2/H3 that
1057
+ //-- have a text label and no .no-auto-anchor class, so links to H1/H4-H6 (or those headings)
1058
+ //-- would otherwise fail and dump the reader at the top of the page. As a last resort we scan
1059
+ //-- every heading, match on its slugified text, and back-fill the id so scroll + future lookups
1060
+ //-- work. Returns a DOM element or null. //-- SG
1061
+ function resolveAnchorElement(strHash)
1062
+ {
1063
+ try
1064
+ {
1065
+ let frag = (strHash || "").replace(/^#/, "");
1066
+ if(!frag) return null;
1067
+ let slug = toSeoUrl(frag);
1068
+ if(!slug) return null;
1069
+
1070
+ //-- 1) direct id match (SEO slug, or an id the author set verbatim)
1071
+ let ele = document.getElementById(slug) || document.getElementById(frag);
1072
+ if(ele) return ele;
1073
+
1074
+ //-- 2) legacy <a name="..."> / [name] targets
1075
+ let named = document.querySelector('[name="' + ((window.CSS && CSS.escape) ? CSS.escape(frag) : frag) + '"]');
1076
+ if(named) return named;
1077
+
1078
+ //-- 3) match any heading by its slugified text and back-fill the id
1079
+ let container = vueContentContainer() || document;
1080
+ let headings = container.querySelectorAll("h1,h2,h3,h4,h5,h6");
1081
+ for(let i=0; i<headings.length; i++)
1082
+ {
1083
+ //-- native .trim(): jQuery 4 removed $.trim() //-- SG
1084
+ if(toSeoUrl(headingText(headings[i]))===slug)
1085
+ {
1086
+ if(!headings[i].id) headings[i].id = slug;
1087
+ return headings[i];
1088
+ }
1089
+ }
1090
+ return null;
1091
+ }
1092
+ catch(e)
1093
+ {
1094
+ hslDocLog.log("Resolve document anchor element by hash failed ["+strHash+"]",e);
1095
+ return null;
1096
+ }
1097
+ }
1098
+
1099
+ //-- Accessibility fixes over freshly-injected article content. These used to live inside
1100
+ //-- listenForHrefClicks(), so they re-scanned the whole document every time a click handler
1101
+ //-- was rebound - several times per navigation - even though the DOM they fix only changes
1102
+ //-- when new content is injected. Note the attribute-substring selectors below cannot use
1103
+ //-- any index and walk every element, so keeping this to once per page matters. //-- SG
1104
+ function applyContentAccessibilityFixes()
1105
+ {
1106
+ //-- Accessibility fix: add aria-label and id to menu popper anchors
1107
+ document.querySelectorAll(".documentation-menu-popper").forEach(function(el,index) {
1108
+ if(!el.getAttribute("aria-label")) {
1109
+ el.setAttribute("aria-label", "Document options menu");
1110
+ el.setAttribute("id", "doc-menu-popper-" + index);
1111
+ el.querySelectorAll("i").forEach(function(i){ i.setAttribute("aria-hidden","true"); });
1112
+ }
1113
+ });
1114
+
1115
+ //-- Accessibility fix: add aria-label to edit links (GitHub pencil icon)
1116
+ document.querySelectorAll(".toolbar-action").forEach(function(link) {
1117
+ let href = link.getAttribute("href") || "";
1118
+ if(href.includes("github.com") && link.querySelector(".bi-pencil") && !link.getAttribute("aria-label")) {
1119
+ link.setAttribute("aria-label", "Edit this page on GitHub");
1120
+ link.querySelectorAll("i").forEach(function(i){ i.setAttribute("aria-hidden","true"); });
1121
+ }
1122
+ });
1123
+
1124
+ //-- Accessibility fix: override inline gray colors for WCAG AAA compliance
1125
+ var isDarkMode = document.documentElement.classList.contains('dark');
1126
+ var accessibleGray = isDarkMode ? "#C4C4C4" : "#525252"; /* C4C4C4 = 7.5:1 on #242424 */
1127
+ document.querySelectorAll("[style*='color: gray'], [style*='color:gray'], [style*='color: rgb(82'], [style*='color:rgb(82']").forEach(function(el) {
1128
+ el.style.color = accessibleGray;
1129
+ });
1130
+ }
1131
+
1132
+ //-- Trap link clicks so we can cancel them and load content inline instead.
1133
+ //-- Bound ONCE on document as a delegated handler by bindDelegatedLinkHandler() below.
1134
+ //-- It used to be attached directly to every matching element and torn down/rebound on
1135
+ //-- every content injection - thousands of unbind/bind cycles per navigation on a large
1136
+ //-- API reference page - which is also why every code path that injected markup had to
1137
+ //-- remember to re-run the binder. Delegation removes that constraint entirely: anchors
1138
+ //-- created later (by Vue, by the tab strip, by anything) are handled with no rebinding.
1139
+ //--
1140
+ //-- `this` is still the matched element, so the body is unchanged from the bound version.
1141
+ //-- Ordering note: Vue's own @click handlers sit directly on their elements and therefore
1142
+ //-- run first now. The ones that matter (loadLibraryBook, searchDocuments) call
1143
+ //-- stopImmediatePropagation(), so they still suppress this handler - more reliably than
1144
+ //-- before, when it came down to which handler happened to be registered first. //-- SG
1145
+ function handleDocumentLinkClick(ev)
1146
+ {
1147
+ let ele = this;
1148
+ ///hslDocLog.log("OnAClick: ", ele.getAttribute("href"));
1149
+
1150
+ //-- clicked the header - so get teh child link
1151
+ if(ele.tagName==="H2" || ele.tagName==="H3")
1152
+ {
1153
+ ele = ele.querySelector(".header-anchor");
1154
+ }
1155
+
1156
+ if(ele && ele.href)
1157
+ {
1158
+ //hslDocLog.log("XXX: ", ele.getAttribute("href"));
1159
+ const checkUrl = new URL(ele.href);
1160
+
1161
+ //-- same-page fragment link (raw href starts with #). Because <base href> is the
1162
+ //-- site root, a bare "#anchor" resolves ele.href to the site root + hash (not the
1163
+ //-- current page), so the book-loading logic below can't detect it. Handle it here:
1164
+ //-- just scroll to the matching anchor on the current page. //-- SG
1165
+ let rawHref = ele.getAttribute("href");
1166
+ if(rawHref && rawHref.charAt(0) === "#")
1167
+ {
1168
+ ev.stopImmediatePropagation();
1169
+ ev.preventDefault();
1170
+ let anchorEle = resolveAnchorElement(rawHref);
1171
+ if(anchorEle)
1172
+ {
1173
+ GotoAndHighlightAnchorContent(anchorEle, false); //-- false = also run border highlight animation, like a direct anchor visit
1174
+ let currUrl = new URL(document.location.href);
1175
+ currUrl.hash = rawHref;
1176
+ if(currUrl.href !== document.location.href) window.history.pushState(null, null, currUrl); //-- add state so back button works
1177
+ }
1178
+ return false;
1179
+ }
1180
+
1181
+ //-- clicking on a hash tag header link element - so jsut scroll to element
1182
+ if(checkUrl.href===siteBaseLocation)
1183
+ {
1184
+ //-- we want to go to home page
1185
+ }
1186
+ else if(checkUrl.origin +"/"===siteBaseLocation && ele.getAttribute("target")!=="_blank")
1187
+ {
1188
+
1189
+ //-- if a url that is part of this doc site then handle loading content inline else let link do whatever (i.e. its an external link)
1190
+ ev.stopImmediatePropagation();
1191
+ ev.preventDefault();
1192
+
1193
+ //-- a search result click always loads its book, whatever the url shape - this
1194
+ //-- test used to be duplicated token-for-token inside both arms of the anchor
1195
+ //-- check below, so it is hoisted in front of it //-- SG
1196
+ if(ele.classList.contains("searchresult-href"))
1197
+ {
1198
+ //-- we are loading a search result from a book
1199
+ let loadBookId = ele.getAttribute("bookid");
1200
+ hslDocLog.log("Search Result Click : Load Book ("+loadBookId+") Then ContentURL: ", ele.getAttribute("href"));
1201
+
1202
+ //-- remember search history
1203
+ storeUrlInHistory(getSearchUrlLink());
1204
+
1205
+ //-- now set new url for doc we want to load
1206
+ storeUrlInHistory(ele.getAttribute("href"));
1207
+
1208
+ view.docApp.searchMode = false;
1209
+ view.docApp.mcpCatalogMode = false;
1210
+ view.loadLibraryBook({docId:loadBookId},ele.getAttribute("href"));
1211
+ return false;
1212
+ }
1213
+
1214
+ //-- do we need to scroll into view element on this page
1215
+ //-- add fix make use url clicked on is same path as current one
1216
+ let eleAnc = (checkUrl.pathname===document.location.pathname && checkUrl.hash)?getAnchorFromHash(checkUrl.hash,"faq-toc-item"):null;
1217
+ if(eleAnc)
1218
+ {
1219
+ GotoAndHighlightAnchorContent(eleAnc,true);
1220
+ if(checkUrl.pathname==="/")
1221
+ {
1222
+ //-- going to element on current page so just update current browser url with new #
1223
+ let currUrl = new URL(document.location.href);
1224
+ currUrl.hash=checkUrl.hash;
1225
+ if(currUrl.href!==document.location.href) window.history.pushState(null, null, currUrl); //-- user has clicked a link so we want to add state to history so we can click back
1226
+ }
1227
+ else if(ele.href!==document.location.href)
1228
+ {
1229
+ window.history.pushState(null, null, ele.href); //-- user has clicked a link so we want to add state to history so we can click back
1230
+ }
1231
+ }
1232
+ else
1233
+ {
1234
+ //-- we need to check if the bookId is the same as current one -if it is not then need to do a full load
1235
+ let targetUrl = checkUrl.href.replace("/_books/","/");
1236
+ let targetBookId = targetUrl.replace(checkUrl.origin +"/","").split("/",1)[0];
1237
+ let currentBookId = document.location.href.replace(document.location.origin +"/","").split("/",1)[0];
1238
+ if(targetBookId==currentBookId)
1239
+ {
1240
+ //-- load content inline - no need to change book
1241
+ hslDocLog.log("LoadContentURL: ", targetUrl);
1242
+ loadContentUrl(targetUrl,false,false);
1243
+ }
1244
+ else
1245
+ {
1246
+ //-- load new book
1247
+ hslDocLog.log("Load new book ("+targetBookId+") URL : ", targetUrl);
1248
+ findBookByIdAndLoad(targetBookId,targetUrl);
1249
+
1250
+ }
1251
+ }
1252
+ return false;
1253
+ }
1254
+ else
1255
+ {
1256
+ //-- if not part of this website then always open in new tab as considered an external site
1257
+ ele.setAttribute("target","_blank");
1258
+ }
1259
+ }
1260
+ }
1261
+
1262
+ //-- Attach the single delegated click handler. Called once from intialiseApp().
1263
+ var boolDelegatedLinkHandlerBound = false;
1264
+ function bindDelegatedLinkHandler()
1265
+ {
1266
+ if(boolDelegatedLinkHandlerBound) return;
1267
+ boolDelegatedLinkHandlerBound = true;
1268
+ document.addEventListener("click",function(ev){
1269
+ //-- native equivalent of jQuery's delegated .on(selector,...): find the nearest matching
1270
+ //-- ancestor of the event target and invoke the handler with `this` set to it, which is
1271
+ //-- what the handler body reads. //-- SG
1272
+ let matched = ev.target && ev.target.closest ? ev.target.closest("a, h2.faq-toc-item, h3.faq-toc-item") : null;
1273
+ if(matched) handleDocumentLinkClick.call(matched,ev);
1274
+ });
1275
+ }
1276
+
1277
+ var lastSavedUrl = window.location.href;
1278
+ function storeUrlInHistory(strUrl)
1279
+ {
1280
+ if(strUrl!==lastSavedUrl)
1281
+ {
1282
+ window.history.pushState(null, null, strUrl);
1283
+ lastSavedUrl = strUrl;
1284
+ }
1285
+ }
1286
+
1287
+ //-- The one place the /search query params are assembled, for BOTH the _search request and
1288
+ //-- the browser url, so the two can never disagree. They did: this used to build q/bid only
1289
+ //-- while searchDocuments built q/bid/audience/dt, and the delayed pushState in the response
1290
+ //-- handler then overwrote the correct url with the short one - a filtered search could not
1291
+ //-- be copied, bookmarked or back-buttoned, and popstate (which does read dt/audience) had
1292
+ //-- nothing to restore. Add any new facet here, not at a call site. //-- SG
1293
+ function buildSearchQueryParams(searchText)
1294
+ {
1295
+ let queryParams = {q:searchText};
1296
+ if(view.docApp.searchFilterBySpecificBookId) queryParams.bid = view.docApp.searchFilterBySpecificBookId;
1297
+ if(view.docApp.showAudienceFilter && view.docApp.searchAudienceFilter) queryParams.audience = view.docApp.searchAudienceFilter;
1298
+ if(view.docApp.searchDocTypeFilter) queryParams.dt = view.docApp.searchDocTypeFilter;
1299
+ return queryParams;
1300
+ }
1301
+
1302
+ //-- based on current search criteria return what the window url would be
1303
+ function getSearchUrlLink()
1304
+ {
1305
+ let urlToSet = window.location.origin + "/search";
1306
+ let searchText = view.docApp.currentResultsSearchText;
1307
+ if(searchText)
1308
+ {
1309
+ urlToSet += "?" + encodeQueryData(buildSearchQueryParams(searchText));
1310
+ }
1311
+ return urlToSet;
1312
+ }
1313
+
1314
+
1315
+ //-- getProductFamilyCount() lived here. Its only caller, resetFilteredProductsListCounters(),
1316
+ //-- now builds one Map of the counts instead of rescanning productFamiliesInfo per product. //-- SG
1317
+
1318
+ function resetCheckedProducts()
1319
+ {
1320
+ for(let x=0;x<view.docApp.products.length;x++)
1321
+ {
1322
+ view.docApp.products[x].checked=false;
1323
+ }
1324
+ }
1325
+ function resetFilteredProductsListCounters()
1326
+ {
1327
+ //-- one pass over productFamiliesInfo instead of getProductFamilyCount() rescanning it per
1328
+ //-- product //-- SG
1329
+ let familyCounts = new Map();
1330
+ if(view.docApp.searchResponse && view.docApp.searchResponse.productFamiliesInfo)
1331
+ {
1332
+ let productFamiliesInfo = view.docApp.searchResponse.productFamiliesInfo;
1333
+ for(let x=0;x<productFamiliesInfo.length;x++) familyCounts.set(productFamiliesInfo[x].productFamilyId,productFamiliesInfo[x].foundCount);
1334
+ }
1335
+
1336
+ view.docApp.searchFilteredProducts["unknown"] = {checked:false,returnedCount:0};
1337
+ for(let x=0;x<view.docApp.products.length;x++)
1338
+ {
1339
+ let strProductID = view.docApp.products[x].id;
1340
+ if(!view.docApp.searchFilteredProducts[strProductID])
1341
+ {
1342
+ view.docApp.searchFilteredProducts[strProductID] = {};
1343
+ }
1344
+ view.docApp.searchFilteredProducts[strProductID].checked = view.docApp.products[x].checked || false;
1345
+ view.docApp.searchFilteredProducts[strProductID].returnedCount = familyCounts.get(strProductID) || 0;
1346
+ }
1347
+ }
1348
+
1349
+ function updateBooksEnableDisableStateBasedOnFilteredProducts(checkedProdArray)
1350
+ {
1351
+ //-- booksInfo is a field on the server's search response, so treat it as optional rather than
1352
+ //-- assuming every response carries one //-- SG
1353
+ if(!view.docApp.searchResponse || !view.docApp.searchResponse.booksInfo) return;
1354
+
1355
+ //-- only enable/disable books filter options depending on if book.productFamilyId is one of the checked products
1356
+ view.docApp.searchResponse.booksInfo.forEach( function(aBook){
1357
+ if(checkedProdArray.length)
1358
+ {
1359
+ aBook.productFilterDisable = true;
1360
+ //-- one or more products checked so only enable books that match checked products
1361
+ checkedProdArray.forEach(function(aProductId)
1362
+ {
1363
+ //-- this book belongs to a product family that is checked so enable it's selection
1364
+ if(view.docApp.searchFilteredProducts[aProductId] && view.docApp.searchFilteredProducts[aProductId].checked && aBook.productFamilyId===aProductId)aBook.productFilterDisable = false;
1365
+ });
1366
+ }
1367
+ else
1368
+ {
1369
+ aBook.productFilterDisable = false;
1370
+ }
1371
+ });
1372
+
1373
+
1374
+
1375
+ }
1376
+
1377
+ //-- Books filter list order - alphabetical, case-insensitive, by display title (bookId fallback
1378
+ //-- covers entries whose title did not resolve from the library) //-- SG
1379
+ function sortBooksInfoByTitle()
1380
+ {
1381
+ if(!view.docApp.searchResponse || !Array.isArray(view.docApp.searchResponse.booksInfo)) return;
1382
+ view.docApp.searchResponse.booksInfo.sort(function(a,b)
1383
+ {
1384
+ return (a.title || a.bookId || "").localeCompare(b.title || b.bookId || "", undefined, {sensitivity:"base"});
1385
+ });
1386
+ }
1387
+
1388
+ function prepareSearchResultItems(arrSearchResults)
1389
+ {
1390
+ let checkedProdArray = getCheckedProductFilterArray();
1391
+ resetFilteredProductsListCounters();
1392
+ updateBooksEnableDisableStateBasedOnFilteredProducts(checkedProdArray);
1393
+
1394
+
1395
+
1396
+
1397
+ //hslDocLog.log(view.docApp.searchFilteredProducts)
1398
+ //-- built locally and assigned once at the end: pushing onto the reactive array fired 300
1399
+ //-- separate reactive triggers, and left the model briefly empty part-way through. //-- SG
1400
+ let filteredItems = [];
1401
+ for(let x=0;x<arrSearchResults.length;x++)
1402
+ {
1403
+ //-- if we don't have a title get it from content ?? bit flakey
1404
+ if(!arrSearchResults[x].title)
1405
+ {
1406
+ arrSearchResults[x].title = arrSearchResults[x].content.split("\n\n",1)[0];
1407
+ }
1408
+
1409
+ //-- create link from url (remove the index.html)
1410
+ arrSearchResults[x].url = arrSearchResults[x].url.replace("/index.html","");
1411
+
1412
+ //-- Replace snippet highlights. The snippet is rendered with v-html (the only way to
1413
+ //-- show the <mark> highlights), and doc content can contain literal HTML - example
1414
+ //-- code, event handlers - so it MUST be escaped first or it executes in the searcher's
1415
+ //-- browser (stored XSS). Escape everything, then turn the plain-text highlight markers
1416
+ //-- into the only tags allowed through.
1417
+ //-- Once per record only: this function re-runs over the SAME response array on every
1418
+ //-- product/book filter click, and escaping is not idempotent - a second pass turned
1419
+ //-- the first pass's <mark> tags into visible "&lt;mark&gt;" text, worse each click,
1420
+ //-- until a fresh search replaced the records. //-- SG
1421
+ if(!arrSearchResults[x].content)arrSearchResults[x].content = "";
1422
+ if(!arrSearchResults[x].contentPrepared)
1423
+ {
1424
+ arrSearchResults[x].content = arrSearchResults[x].content
1425
+ .replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;').replaceAll("'",'&#39;')
1426
+ .replaceAll('[hb_search_highlight]', '<mark class="highlight">').replaceAll('[/hb_search_highlight]', '</mark>');
1427
+ arrSearchResults[x].contentPrepared = true;
1428
+ }
1429
+
1430
+ //-- increase count for product so we can show on search filters
1431
+ let resProdId = arrSearchResults[x].productFamily || "unknown";
1432
+ //-- nwj - 15th Oct 2024 - we have to do this manually as counts for products returned from search arent useful
1433
+ //if(view.docApp.searchFilteredProducts[resProdId])view.docApp.searchFilteredProducts[resProdId].returnedCount++;
1434
+
1435
+ let includeInResults = true;
1436
+
1437
+ if(checkedProdArray.length) //-- are nay checked
1438
+ {
1439
+ if(view.docApp.searchFilteredProducts[resProdId])
1440
+ {
1441
+ includeInResults = view.docApp.searchFilteredProducts[resProdId].checked;
1442
+ }
1443
+ else
1444
+ {
1445
+ includeInResults = false;
1446
+ }
1447
+ }
1448
+
1449
+ //-- need to filter by checked book if have any checked
1450
+ if(includeInResults && view.docApp.searchFilterBySpecificBookId)
1451
+ {
1452
+ includeInResults = (arrSearchResults[x].bookId === view.docApp.searchFilterBySpecificBookId);
1453
+ }
1454
+
1455
+ if(includeInResults)
1456
+ {
1457
+ filteredItems.push(arrSearchResults[x]);
1458
+ }
1459
+ }
1460
+
1461
+ //-- one assignment, one render, and the list is replaced rather than emptied then refilled
1462
+ view.docApp.searchResultFilteredItems = filteredItems;
1463
+
1464
+ //- if we have filter by products update the counts of books to 0 or # of results in books
1465
+ }
1466
+
1467
+ function getCheckedProductFilterArray()
1468
+ {
1469
+
1470
+
1471
+
1472
+ let retArray = [];
1473
+ //-- now check if result is ok if we have any product filter set
1474
+ for(let productId in view.docApp.searchFilteredProducts)
1475
+ {
1476
+ if(view.docApp.searchFilteredProducts[productId].checked)
1477
+ {
1478
+ retArray.push(productId);
1479
+ }
1480
+ }
1481
+ return retArray;
1482
+ }
1483
+
1484
+ //-- Lookup indexes for the two lists that get scanned per search response. getBookById was
1485
+ //-- called once per entry in booksInfo and scanned all of library.books each time - O(n*m) on
1486
+ //-- every keystroke-triggered search. The index is rebuilt lazily whenever the underlying array
1487
+ //-- is replaced (identity) or changes length, so no load path has to remember to invalidate it.
1488
+ //-- //-- SG
1489
+ let libraryBooksIndex = null;
1490
+ let libraryBooksIndexSource = null;
1491
+ function getLibraryBooksIndex()
1492
+ {
1493
+ let books = (view.docApp.library && view.docApp.library.books) || [];
1494
+ if(libraryBooksIndexSource !== books || !libraryBooksIndex || libraryBooksIndex.size !== books.length)
1495
+ {
1496
+ libraryBooksIndex = new Map();
1497
+ for(let x=0;x<books.length;x++) libraryBooksIndex.set(books[x].docId,books[x]);
1498
+ libraryBooksIndexSource = books;
1499
+ }
1500
+ return libraryBooksIndex;
1501
+ }
1502
+
1503
+ function getBookById(strBookId)
1504
+ {
1505
+ return getLibraryBooksIndex().get(strBookId);
1506
+ }
1507
+
1508
+ //-- based on content url highlight match navigation menu item
1509
+ function highlightNavigationLinkFromUrl(matchLinkHref)
1510
+ {
1511
+
1512
+ matchLinkHref = removeStartingSlash(matchLinkHref).split("#")[0]; //-- remvoe # link
1513
+ document.querySelectorAll("a.DocLink").forEach(function(el){ el.classList.remove("active"); });
1514
+
1515
+ let arrItems = document.querySelectorAll('a.DocLink');
1516
+
1517
+ for(let x=0;x<arrItems.length;x++)
1518
+ {
1519
+ let el = arrItems[x];
1520
+ let checkUrl = null;
1521
+ try{
1522
+ checkUrl = new URL(el.href);
1523
+ }
1524
+ catch(e)
1525
+ {
1526
+ //hslDocLog.log("BAD navigation menu item found",el);
1527
+ }
1528
+
1529
+ if(checkUrl)
1530
+ {
1531
+ let testCurrPath = removeStartingSlash(checkUrl.pathname);
1532
+ if(testCurrPath===matchLinkHref || testCurrPath.indexOf(matchLinkHref)===0 || matchLinkHref.indexOf(removeStartingSlash(checkUrl.pathname) + "/")===0)
1533
+ {
1534
+ //-- exact match
1535
+ el.classList.add("active")
1536
+ if(!elementIsOnVisibleScreen(el))el.scrollIntoView();
1537
+ return false;//break out
1538
+ }
1539
+ }
1540
+ }
1541
+ }
1542
+
1543
+ //-- create items to stick in toc from current loaded content
1544
+ //-- searches for H2,H3
1545
+ //-- Heading text for slugging, WITHOUT depending on the heading being rendered.
1546
+ //--
1547
+ //-- This used to read innerText in both callers. innerText is layout-dependent: it returns ""
1548
+ //-- for anything not currently rendered. That was safe only while the content reveal was a
1549
+ //-- synchronous class removal ($(".hb-hidden").removeClass). The reveal is now reactive state
1550
+ //-- (docApp.contentLoading), which Vue applies on the NEXT tick - and generateTableOfContents-
1551
+ //-- FromDoc runs on the tick before it, while the article is still visibility:hidden. Every
1552
+ //-- heading therefore read as empty: no ids assigned, so no #anchor deep links resolved and no
1553
+ //-- section highlight fired, and the table of contents came back empty so the right-hand nav
1554
+ //-- never rendered.
1555
+ //--
1556
+ //-- textContent has no layout dependency. It is equivalent for these headings because the only
1557
+ //-- thing appended to them is <a class="header-anchor"><i></i></a>, which carries no text.
1558
+ //-- Whitespace is collapsed so the slug matches what innerText produced. //-- SG
1559
+ function headingText(el)
1560
+ {
1561
+ return ((el && el.textContent) || "").replace(/\s+/g," ").trim();
1562
+ }
1563
+
1564
+ function generateTableOfContentsFromDoc()
1565
+ {
1566
+ let container = vueContentContainer();
1567
+ if(container)
1568
+ {
1569
+ const currUrl = new URL(document.location.href);
1570
+ view.docApp.tableOfContents = [];
1571
+ for (let i=2; i<=3; i++)
1572
+ {
1573
+ let headers = container.getElementsByTagName("h"+i);
1574
+ for (let j=0; j<headers.length; j++)
1575
+ {
1576
+ //-- only process headers that do not have this class
1577
+ if(!headers[j].classList.contains("no-auto-anchor")) headers[j].classList.add("faq-toc-item");
1578
+ }
1579
+ }
1580
+
1581
+ //-- track ids handed out on this page so two headings with the same text do not
1582
+ //-- both claim the same anchor - deep links used to always land on the first. //-- SG
1583
+ let usedHeadingIds = {};
1584
+
1585
+ var headersTocItem = container.getElementsByClassName("faq-toc-item");
1586
+ for (let k = 0; k < headersTocItem.length; k++)
1587
+ {
1588
+ let item = headersTocItem[k];
1589
+ let strLabel = headingText(item);
1590
+
1591
+ //-- Handle badge-only headings (accessibility fix)
1592
+ if(!strLabel) {
1593
+ let badge = item.querySelector("[class*='badge-endpoint-'], [class*='badge-user-'], [class*='badge-portal-']");
1594
+ if(badge) {
1595
+ let badgeClass = badge.getAttribute("class") || "";
1596
+ let badgeText = "API Type";
1597
+ if(badgeClass.includes("internal")) badgeText = "Internal API";
1598
+ else if(badgeClass.includes("api")) badgeText = "API Endpoint";
1599
+ else if(badgeClass.includes("user")) badgeText = "User API";
1600
+ else if(badgeClass.includes("portal")) badgeText = "Portal API";
1601
+ badge.insertAdjacentHTML("beforeend","<span class='visually-hidden'>" + badgeText + "</span>");
1602
+ strLabel = badgeText;
1603
+ }
1604
+ }
1605
+
1606
+ if(strLabel)
1607
+ {
1608
+ let seoSafeId = toSeoUrl(strLabel); //-- will use SEO to set in url so can scroll to element from url
1609
+
1610
+ //-- de-dupe repeated heading text with a suffix counter
1611
+ if(usedHeadingIds[seoSafeId] === undefined)
1612
+ {
1613
+ usedHeadingIds[seoSafeId] = 0;
1614
+ }
1615
+ else
1616
+ {
1617
+ usedHeadingIds[seoSafeId]++;
1618
+ seoSafeId = seoSafeId + "-" + usedHeadingIds[seoSafeId];
1619
+ }
1620
+
1621
+ let linkUrl = currUrl.pathname + "#" + seoSafeId;
1622
+ item.id = seoSafeId;
1623
+
1624
+ view.docApp.tableOfContents.push({ele:item,eleText:strLabel,tagName:item.tagName,href:linkUrl});
1625
+
1626
+ //-- add link icon to header (using seo id). Set the href on the appended anchor
1627
+ //-- only - a find("a") here overwrote the href of any author-written link inside
1628
+ //-- the heading as well. //-- SG
1629
+ //-- built as an element rather than an HTML string. The label is heading text and
1630
+ //-- was interpolated straight into markup, which is why it needed the quote-stripping
1631
+ //-- hack beside it - and it would still have broken on an angle bracket.
1632
+ //-- setAttribute escapes, so the hack goes with it. //-- SG
1633
+ let anchorEl = document.createElement("a");
1634
+ anchorEl.className = "header-anchor";
1635
+ anchorEl.setAttribute("aria-label","Link to this section: " + strLabel);
1636
+ anchorEl.setAttribute("href",linkUrl);
1637
+ anchorEl.innerHTML = "<i class='bi bi-link' aria-hidden='true'></i>";
1638
+ item.appendChild(anchorEl);
1639
+
1640
+ }
1641
+ }
1642
+ }
1643
+ }
1644
+
1645
+
1646
+ function formatBytes(val)
1647
+ {
1648
+ //-- the old <1024 special case divided anyway and hard-coded 'KB', so a 500-byte file
1649
+ //-- rendered as "0.49KB" and units[0] ('B') was unreachable //-- SG
1650
+ var units = ['B', 'KB', 'MB', 'GB', 'TB'],
1651
+ bytes = val,
1652
+ i;
1653
+
1654
+ for (i = 0; bytes >= 1024 && i < units.length-1; i++) {
1655
+ bytes /= 1024;
1656
+ }
1657
+
1658
+ return bytes.toFixed(2) + units[i];
1659
+ }
1660
+
1661
+ //-- one app per embedded component in the current article - see below for why
1662
+ let currentVueContentApps = [];
1663
+ function convertDocumentContentToVueSafeComponents(strHtml){
1664
+
1665
+ let vueComponentsSelector = "hdoc-approve"; //-- comma sep
1666
+
1667
+ //-- This used to compile the whole article as one app: blanket-apply v-pre to every element so
1668
+ //-- authored {{...}} in prose stayed literal, then removeAttr("v-pre") from the embedded
1669
+ //-- component tags to let those compile. That last step never actually worked. v-pre is
1670
+ //-- INHERITED by the whole subtree, so clearing it on a descendant does nothing once an
1671
+ //-- ancestor has stopped compilation - any embedded component that was not a direct child of
1672
+ //-- .vue-document-body was silently inert. Verified against Vue 2.7.16 as well as Vue 3: a
1673
+ //-- nested <hdoc-approve> rendered under neither. It was a live bug, not a migration casualty.
1674
+ //--
1675
+ //-- So we stop compiling the article altogether. Author HTML is left exactly as injected -
1676
+ //-- which makes {{...}} in prose literal by construction rather than by directive, and keeps
1677
+ //-- arbitrary author markup out of the template compiler - and we mount one small app per
1678
+ //-- embedded component element instead. Those work at any nesting depth, which also fixes the
1679
+ //-- inert-nested-component bug. //-- SG
1680
+
1681
+ //-- tear down last article's component apps (Vue 3 has no vm.$destroy(); teardown is an
1682
+ //-- app-level operation, so hbVue.unmount looks up the app that owns the vm)
1683
+ currentVueContentApps.forEach(function(vm){ hbVue.unmount(vm); });
1684
+ currentVueContentApps = [];
1685
+
1686
+ let vueContainer = vueContentContainer();
1687
+ if(!vueContainer) return;
1688
+
1689
+ vueContainer.innerHTML = strHtml;
1690
+
1691
+ vueContainer.querySelectorAll(vueComponentsSelector).forEach(function(el){
1692
+
1693
+ let componentName = el.tagName.toLowerCase();
1694
+
1695
+ //-- carry the element's attributes over as props. hdoc-approve declares no props, so they
1696
+ //-- arrive on $attrs, which is where it already reads reference/assignee from.
1697
+ let props = {};
1698
+ for(let x=0;x<el.attributes.length;x++){
1699
+ props[el.attributes[x].name] = el.attributes[x].value;
1700
+ }
1701
+
1702
+ //-- mounting on the element itself keeps the DOM position; a render function means Vue
1703
+ //-- ignores the element's innerHTML rather than treating it as a template. resolveComponent
1704
+ //-- has to be called during render so it sees this app's component registry.
1705
+ currentVueContentApps.push(hbMount({
1706
+ render:function(){
1707
+ return Vue.h(Vue.resolveComponent(componentName), props);
1708
+ }
1709
+ }, el));
1710
+
1711
+ });
1712
+ }
1713
+ //-- processing to do after book content has been injected into view
1714
+ //-- Mermaid client-side rendering config — mirrors hdoc-tools templates/mermaid-theme.yaml.
1715
+ //-- (PDFs are still rendered to SVG server-side by hdoc build; this is HTML-only.)
1716
+ var HB_MERMAID_CONFIG = {
1717
+ startOnLoad: false,
1718
+ theme: "base",
1719
+ themeVariables: {
1720
+ background: "transparent",
1721
+ fontSize: "14px",
1722
+ primaryColor: "#85D2FF",
1723
+ primaryColorLight: "#85D2FF",
1724
+ primaryColorDark: "#0065CB",
1725
+ secondaryColor: "#219BFF",
1726
+ secondaryColorLight: "#85D2FF",
1727
+ secondaryColorDark: "#0C4C8C",
1728
+ tertiaryColor: "#219BFF",
1729
+ tertiaryColorLight: "#85D2FF",
1730
+ tertiaryColorDark: "#0C4C8C",
1731
+ lineColor: "#808080",
1732
+ border1: "#606060",
1733
+ textColor: "#A0A0A0",
1734
+ titleColor: "#A0A0A0",
1735
+ labelColor: "#404040",
1736
+ loopTextColor: "#A0A0A0",
1737
+ nodeTextColor: "#404040",
1738
+ labelTextColor: "#404040",
1739
+ pieSectionTextColor: "#404040",
1740
+ nodeBorder: "#219BFF",
1741
+ nodeBg: "transparent"
1742
+ }
1743
+ };
18
1744
 
19
1745
  //-- Lazy-load the Mermaid bundle (~3MB) only when a page actually contains a
20
1746
  //-- diagram. Resolves to window.mermaid, initialised once.
21
- let hbMermaidLoadPromise = null;
22
- function hbLoadMermaid() {
23
- if (window.mermaid) return Promise.resolve(window.mermaid);
24
- if (hbMermaidLoadPromise) return hbMermaidLoadPromise;
25
- hbMermaidLoadPromise = hbFetchMermaidTheme().then(
26
- (theme) =>
27
- new Promise((resolve, reject) => {
28
- const el = document.createElement("script");
29
- el.src = "js/mermaid.min.js"; //-- resolves against <base href> (site root)
30
- el.onload = () => {
31
- try {
32
- window.mermaid.initialize(
33
- Object.assign(
34
- { startOnLoad: false },
35
- theme || { theme: "base" },
36
- ),
37
- );
38
- resolve(window.mermaid);
39
- } catch (e) {
40
- reject(e);
41
- }
42
- };
43
- el.onerror = () =>
44
- reject(new Error("failed to load js/mermaid.min.js"));
45
- document.head.appendChild(el);
46
- }),
47
- );
48
- return hbMermaidLoadPromise;
1747
+ var hbMermaidLoadPromise = null;
1748
+ function hbLoadMermaid()
1749
+ {
1750
+ if(window.mermaid) return Promise.resolve(window.mermaid);
1751
+ if(hbMermaidLoadPromise) return hbMermaidLoadPromise;
1752
+ hbMermaidLoadPromise = new Promise(function(resolve,reject)
1753
+ {
1754
+ var el = document.createElement("script");
1755
+ el.src = "js/mermaid.min.js"; //-- resolves against <base href> (site root)
1756
+ el.onload = function()
1757
+ {
1758
+ try{ window.mermaid.initialize(HB_MERMAID_CONFIG); resolve(window.mermaid); }
1759
+ catch(e){ reject(e); }
1760
+ };
1761
+ el.onerror = function(){ reject(new Error("failed to load js/mermaid.min.js")); };
1762
+ document.head.appendChild(el);
1763
+ });
1764
+ return hbMermaidLoadPromise;
49
1765
  }
50
1766
 
1767
+ //-- Render any not-yet-processed <pre class="mermaid"> blocks in the current doc.
1768
+ //-- mermaid.run() tags each node with data-processed, so re-running after a
1769
+ //-- navigation only renders freshly-injected diagrams.
51
1770
  //-- Inject the spinner styles once. While a <pre class="mermaid"> has no
52
1771
  //-- data-processed attr (set by mermaid.run() once rendered) we collapse its raw
53
1772
  //-- source text and show a spinner in its place.
54
- function hbEnsureMermaidStyles() {
55
- if (document.getElementById("hb-mermaid-style")) return;
56
- const style = document.createElement("style");
57
- style.id = "hb-mermaid-style";
58
- style.textContent =
59
- "pre.mermaid:not([data-processed]){font-size:0!important;line-height:0!important;min-height:140px;display:flex;align-items:center;justify-content:center;background:none!important;border:none!important;}" +
60
- 'pre.mermaid:not([data-processed])::after{content:"";width:32px;height:32px;border:3px solid rgba(33,155,255,.25);border-top-color:#219BFF;border-radius:50%;animation:hbMermaidSpin .8s linear infinite;}' +
61
- "@keyframes hbMermaidSpin{to{transform:rotate(360deg);}}";
62
- document.head.appendChild(style);
1773
+ function hbEnsureMermaidStyles()
1774
+ {
1775
+ if(document.getElementById("hb-mermaid-style")) return;
1776
+ var style = document.createElement("style");
1777
+ style.id = "hb-mermaid-style";
1778
+ style.textContent =
1779
+ "pre.mermaid:not([data-processed]){font-size:0!important;line-height:0!important;min-height:140px;display:flex;align-items:center;justify-content:center;background:none!important;border:none!important;}" +
1780
+ 'pre.mermaid:not([data-processed])::after{content:"";width:32px;height:32px;border:3px solid rgba(33,155,255,.25);border-top-color:#219BFF;border-radius:50%;animation:hbMermaidSpin .8s linear infinite;}' +
1781
+ "@keyframes hbMermaidSpin{to{transform:rotate(360deg);}}";
1782
+ document.head.appendChild(style);
63
1783
  }
64
1784
 
65
- //-- Render any not-yet-processed <pre class="mermaid"> blocks in the current doc.
66
- //-- mermaid.run() tags each node with data-processed, so re-running after a
67
- //-- navigation only renders freshly-injected diagrams.
68
- function hbRenderMermaidDiagrams() {
69
- hbEnsureMermaidStyles();
70
- const nodes = document.querySelectorAll(
71
- "#DocContent pre.mermaid:not([data-processed])",
72
- );
73
- if (!nodes.length) return;
74
- //-- On any failure, mark nodes processed so the spinner stops (reveals source
75
- //-- rather than spinning forever).
76
- const reveal = (err) => {
77
- hslDocLog.log("Mermaid render failed", err);
78
- for (const n of nodes) n.setAttribute("data-processed", "true");
79
- };
80
- hbLoadMermaid()
81
- .then((mermaid) => mermaid.run({ nodes }))
82
- .catch(reveal);
83
- }
84
-
85
- //-- Warm the Mermaid bundle (~4.5MB) immediately on first script run so it's
86
- //-- downloading/parsing in the background regardless of whether THIS page has a
87
- //-- diagram. By the time the user reaches a diagram page it's ready and the swap
88
- //-- is instant. The bundle is served with a long cache header, so it downloads
89
- //-- once per browser, not once per page.
90
- hbEnsureMermaidStyles();
91
- hbLoadMermaid().catch(() => {});
92
-
93
- const docAppMethods = {
94
- setSessionInfo: function (info) {
95
- this.docApp.userSession = info;
96
- },
97
-
98
- showInlineDocumentList: function (ev) {
99
- if (ev) {
100
- ev.stopImmediatePropagation();
101
- ev.preventDefault();
102
- }
103
- this.docApp.showInlineDocumentsMarkup = true;
104
- this.$forceUpdate();
105
- },
106
- popupInlineDocument: function (ev, navSectionItem) {
107
- if (ev) {
108
- ev.stopImmediatePropagation();
109
- ev.preventDefault();
110
- }
111
-
112
- $("#inlineModal").modal("show");
113
- this.docApp.inlinePopupDocumentHref = navSectionItem.link;
114
- },
115
- switchViewTheme: () => {
116
- if (ThemePreference)
117
- document.documentElement.classList.remove(ThemePreference);
118
-
119
- if (ThemePreference === "dark") {
120
- ThemePreference = "light";
121
- } else {
122
- ThemePreference = "dark";
123
- }
124
-
125
- localStorage.setItem("hdocbook-theme-appearance", ThemePreference);
126
- document.documentElement.classList.add(ThemePreference);
127
- },
128
- renderNavigation: function () {
129
- this.docApp.navSections = this.docApp.book.navigation.items; //-- show be an array of nav groups
130
- },
131
- toggleMobileMenu: () => {
132
- const isOpen = $(".mobile-menu-btn").data("isopen");
133
- if (isOpen) {
134
- $(".DocSidebar").removeClass("open");
135
- } else {
136
- $(".DocSidebar").addClass("open");
137
- }
138
- //-- store new mode
139
- $(".mobile-menu-btn").data("isopen", !isOpen);
140
- },
141
- resetMobileMenu: (ev) => {
142
- if (
143
- ev &&
144
- ($(ev.srcElement).hasClass("mobile-menu-btn")[0] ||
145
- $(ev.srcElement).closest(".mobile-menu-btn")[0])
146
- ) {
147
- //-- skp as have clikc no menu btn
148
- } else {
149
- $(".mobile-menu-btn").data("isopen", false);
150
- $(".DocSidebar").removeClass("open");
151
- }
152
- },
153
- };
1785
+ function hbRenderMermaidDiagrams()
1786
+ {
1787
+ hbEnsureMermaidStyles();
1788
+ var nodes = document.querySelectorAll("#DocContent pre.mermaid:not([data-processed])");
1789
+ if(!nodes.length) return;
1790
+ //-- On any failure, mark nodes processed so the spinner stops (reveals source
1791
+ //-- rather than spinning forever).
1792
+ var reveal = function(err){
1793
+ hslDocLog.log("Mermaid render failed",err);
1794
+ for(var i=0;i<nodes.length;i++) nodes[i].setAttribute("data-processed","true");
1795
+ };
1796
+ hbLoadMermaid().then(function(mermaid){ return mermaid.run({ nodes: nodes }); }).catch(reveal);
1797
+ }
154
1798
 
1799
+ //-- Warm the Mermaid bundle (~3.5MB) in the background so that by the time the
1800
+ //-- user reaches a diagram page it's ready and the swap is instant. The bundle
1801
+ //-- should be served with a long cache header, so it downloads once per browser,
1802
+ //-- not once per page.
155
1803
  //--
156
- //-- user clicked back or forwards
157
- window.addEventListener("popstate", (event) => {
158
- const loadUrl = new URL(event.target.location.href);
159
- loadContentUrl(loadUrl.pathname + loadUrl.hash, {}, false, true);
160
- });
1804
+ //-- Deferred to idle time: this used to fire at script-eval time, which put a
1805
+ //-- 3.5MB fetch + parse in direct competition with first render on every page,
1806
+ //-- the vast majority of which contain no diagram at all. requestIdleCallback
1807
+ //-- yields until the browser has nothing better to do; the timeout option caps
1808
+ //-- how long we'll wait on a permanently busy page. Safari <17 has no
1809
+ //-- requestIdleCallback, hence the setTimeout fallback. //-- SG
1810
+ //--
1811
+ //-- A diagram page does NOT depend on this: hbRenderMermaidDiagrams() calls
1812
+ //-- hbLoadMermaid() itself, and hbLoadMermaid() de-dupes on hbMermaidLoadPromise,
1813
+ //-- so an early navigation just promotes the same in-flight load.
1814
+ hbEnsureMermaidStyles();
1815
+ (function()
1816
+ {
1817
+ var warm = function(){ hbLoadMermaid().catch(function(){}); };
1818
+ if(window.requestIdleCallback) requestIdleCallback(warm,{timeout:5000});
1819
+ else setTimeout(warm,3000);
1820
+ })();
1821
+
1822
+ function postProcessBookContentRender(linkRef,frontmatterData,direct_BooksLinkRef)
1823
+ {
1824
+ //-- do we have a pdf file dl to put into header
1825
+ addPdfIconToHeader(direct_BooksLinkRef);
1826
+
1827
+ //-- get document body mark up - make a clone and add pre-v to all the elements apart from known vue components
1828
+ //-- test vue comps in doc
1829
+ //jqContentContainer().prepend(`<hdoc-approve reference="1234" assignee="gerrysw"></hdoc-approve><hdoc-approve reference="1234" assignee="neilwj"></hdoc-approve>`)
1830
+
1831
+ let bodyEl = contentContainer();
1832
+ convertDocumentContentToVueSafeComponents(bodyEl ? bodyEl.innerHTML : "");
1833
+ if(bodyEl) bodyEl.innerHTML = ""; //-- empty old body
1834
+
1835
+ revealApp();
1836
+
1837
+ //-- Layout class on the content container, from frontmatter - the CSS uses it to size
1838
+ //-- table/list views. One reactive value replaces an add/remove pair against a
1839
+ //-- Vue-rendered element plus global.lastLayoutClass, which was a hand-maintained second
1840
+ //-- copy of the same state purely so the previous class could be taken off again. Vue
1841
+ //-- swaps it for us, so there is nothing to remember. //-- SG
1842
+ view.docApp.layoutClass = frontmatterData.layout || "";
1843
+
1844
+ //--generate toc for all layouts that have h2/h3
1845
+ view.docApp.keepTocLayout = (frontmatterData.layout == "article-no-toc");
1846
+ if(!frontmatterData.layout || frontmatterData.layout==="article" || frontmatterData.layout==="article-toc")
1847
+ {
1848
+ view.docApp.keepTocLayout = true;
1849
+ generateTableOfContentsFromDoc();
1850
+ }
1851
+
1852
+
1853
+ //-- set actual html document title - based on the first h1 found in content
1854
+ let docContentEl = document.getElementById("DocContent");
1855
+ let firstH1 = docContentEl ? docContentEl.querySelector("h1") : null;
1856
+ let docTitle = firstH1 ? firstH1.textContent.trim() : "";
1857
+ let fullTitle = view.docApp.title;
1858
+ if(docTitle){
1859
+ fullTitle += " - " + docTitle;
1860
+ }
1861
+ else{
1862
+ fullTitle += (view.docApp.frontmatterData && view.docApp.frontmatterData.title)? " - " + view.docApp.frontmatterData.title:"";
1863
+ }
1864
+ document.title = fullTitle;
1865
+
1866
+ //-- Set the canonical link - the url we want search engines to treat as authoritative.
1867
+ //-- Reuse the existing tag rather than appending a new one. This used to append on every
1868
+ //-- render, so a session that visited N articles left N <link rel="canonical"> tags in
1869
+ //-- <head>, all but the first stale. Crawlers take the first one, so every article
1870
+ //-- navigated to in-app advertised the canonical of whichever page was loaded first.
1871
+ //--
1872
+ //-- Also strip the query string and hash: ?theme=dark and #section are view state, not
1873
+ //-- distinct documents, and leaving them in invites duplicate-content splitting. //-- SG
1874
+ const canonicalUrl = document.location.origin + document.location.pathname;
1875
+ let linkTag = document.head.querySelector('link[rel="canonical"]');
1876
+ if(!linkTag)
1877
+ {
1878
+ linkTag = document.createElement('link');
1879
+ linkTag.setAttribute('rel', 'canonical');
1880
+ document.head.appendChild(linkTag);
1881
+ }
1882
+ linkTag.href = canonicalUrl;
1883
+ hslDocLog.log("Set canonical link to",canonicalUrl);
1884
+
1885
+ //-- find any <tabs> and andd a bootstrap tab item strip
1886
+ (document.getElementById("DocContent") || document).querySelectorAll("tabs").forEach((el) => {
1887
+
1888
+ let tabClass = el.getAttribute("tabstyle");
1889
+ let tabMarkup = "<ul class='nav nav-tabs'>";
1890
+ //-- for each tab we need to create a tab link
1891
+ el.querySelectorAll("tab").forEach((aTab,idx) => {
1892
+
1893
+ let activeClass=(idx===0)?" active":"";
1894
+ tabMarkup += `<li class='nav-item c-pointer' contentidx='${idx}'><span class='nav-link${activeClass}'>${aTab.getAttribute("name")}</span></li>`;
1895
+
1896
+ //-- hide the tab element if it is not the active one (first)
1897
+ if(idx>0)aTab.classList.add("d-none");
1898
+
1899
+ });
1900
+
1901
+ tabMarkup += "</ul>";
1902
+ //-- inject markup
1903
+ el.insertAdjacentHTML("afterbegin",tabMarkup);
1904
+ let tabStrip = el.querySelector(".nav.nav-tabs");
1905
+ if(!tabStrip) return;
1906
+ if(tabClass) tabStrip.classList.add(tabClass);
1907
+
1908
+ //-- Handlers bound on the strip we just built, NOT on every <li> descendant of <tabs>.
1909
+ //-- The jQuery version used find("li"), which also matched list items inside the tab
1910
+ //-- CONTENT: clicking one of those ran this handler with contentidx null, which cleared
1911
+ //-- .active from every tab and highlighted none. //-- SG
1912
+ tabStrip.querySelectorAll("li").forEach((li) => {
1913
+ li.addEventListener("click",function()
1914
+ {
1915
+ let contentIdx = this.getAttribute("contentidx");
1916
+ let tabsContainer = this.closest("tabs");
1917
+ if(!tabsContainer) return;
1918
+ let tabs = tabsContainer.querySelectorAll("tab");
1919
+
1920
+ //-- hide other tab content and show this tabs content
1921
+ tabs.forEach(function(t){ t.classList.add("d-none"); });
1922
+ if(tabs[contentIdx-0]) tabs[contentIdx-0].classList.remove("d-none");
1923
+
1924
+ //-- set clicked tab item to active
1925
+ tabsContainer.querySelectorAll(".nav-link").forEach(function(nl){ nl.classList.remove("active"); });
1926
+ this.querySelectorAll(".nav-link").forEach(function(nl){ nl.classList.add("active"); });
1927
+ });
1928
+ });
1929
+ });
1930
+
1931
+
1932
+ //-- do any code highlighting
1933
+ if(hljs)
1934
+ {
1935
+ //-- scoped to the document view rather than the whole page. Same set of blocks in
1936
+ //-- practice (both .document-body and .vue-document-body live under #DocContent), but
1937
+ //-- it stops the scan walking the shell, the sidebar and the search UI on every
1938
+ //-- render, and it means a code block added to the chrome later cannot be
1939
+ //-- accidentally highlighted. //-- SG
1940
+ let highlightRoot = document.getElementById("DocContent") || document;
1941
+ highlightRoot.querySelectorAll('pre code').forEach((el) => {
1942
+
1943
+ //-- Don't hand Mermaid blocks to highlight.js. There is no mermaid grammar, so it
1944
+ //-- logs "Could not find the language 'mermaid'" and no-highlights anyway; worse,
1945
+ //-- the build-time variant of these blocks holds a pre-rendered <img> rather than
1946
+ //-- text, and hljs 11 warns "code block includes unescaped HTML ... potentially
1947
+ //-- serious security risk" for any element it is asked to highlight that contains
1948
+ //-- child elements. Both were silent under hljs 9 but are pure console noise, and
1949
+ //-- highlighting an image was never meaningful. //-- SG
1950
+ if(el.classList.contains("language-mermaid") || el.querySelector("img")) return;
1951
+
1952
+ //-- Skip anything already highlighted. Content reaches this loop more than once per
1953
+ //-- navigation (it is highlighted in .document-body, then the HTML is copied into
1954
+ //-- .vue-document-body), and re-highlighting an element whose children are hljs's
1955
+ //-- own <span class="hljs-*"> output is what triggers the "unescaped HTML"
1956
+ //-- security warning in hljs 11. Test the class, not data-highlighted: the copied
1957
+ //-- markup keeps the "hljs" class but not always the attribute. //-- SG
1958
+ if(el.classList.contains("hljs") || el.getAttribute("data-highlighted") === "yes") return;
1959
+
1960
+ //-- highlightElement, not highlightBlock: deprecated in highlight.js 10 and
1961
+ //-- REMOVED in 11, so the old name is now undefined. //-- SG
1962
+ hljs.highlightElement(el);
1963
+ });
1964
+ }
1965
+ else
1966
+ {
1967
+ hslDocLog.log("code highlighting js could not be loaded. code block highlighting will not be enabled");
1968
+ }
1969
+
1970
+ //-- render any Mermaid diagrams client-side (lazy-loads the bundle)
1971
+ hbRenderMermaidDiagrams();
1972
+
1973
+ //-- show copy button by any code blocks
1974
+ var options = {
1975
+ contentSelector: ".injected-document-content",
1976
+ // Delay in ms used for `setTimeout` before badging is applied
1977
+ // Use if you need to time highlighting and badge application
1978
+ // since the badges need to be applied afterwards.
1979
+ // 0 - direct execution (ie. you handle timing
1980
+ loadDelay:0,
1981
+
1982
+ // CSS class(es) used to render the copy icon.
1983
+ copyIconClass: "bi bi-clipboard",
1984
+ // CSS class(es) used to render the done icon.
1985
+ checkIconClass: "bi bi-clipboard-check --htl-c-brand",
1986
+
1987
+ // intercept text copying - passed in text return text out
1988
+ onBeforeCodeCopied: function(text) {
1989
+ return text;
1990
+ }
1991
+ };
1992
+
1993
+ highlightJsBadge(options);
1994
+
1995
+
1996
+ //-- THIS IS A SPECIAL CALL TO ANY TOP WHERE UI MIGHT EMBED DOC IN IFRAME - THIS ALLOWS THAT SRC TO RESIZE IFRAME
1997
+ if(parent && parent.postMessage)
1998
+ {
1999
+ let dataMessage = {};
2000
+ dataMessage.type = "HornbillHelpDocLoaded";
2001
+ dataMessage.info = {height: document.getElementById("DocContent").scrollHeight,
2002
+ width: document.getElementById("DocContent").scrollWidth,
2003
+ frontmatter:frontmatterData};
2004
+
2005
+ parent.postMessage(dataMessage,"*");
2006
+ }
2007
+
2008
+ //-- highlight the nav link for this page and honour any #hash in the url. The same four
2009
+ //-- statements used to be duplicated across both arms below; only the $nextTick wrapper
2010
+ //-- differed. No updateCounter++ / $forceUpdate() in either path: the `expand = true`
2011
+ //-- writes in expandNavParentSectionBySelectedLinkHref go through the reactive proxy and
2012
+ //-- schedule the patch on their own, and nav "active" highlighting is applied with jQuery
2013
+ //-- classes in this app, not a Vue binding, so it never depended on a forced repaint. //-- SG
2014
+ let finishRender = function()
2015
+ {
2016
+ //-- find any navigation links that match url and highlight
2017
+ applyContentAccessibilityFixes();
2018
+ highlightNavigationLinkFromUrl(linkRef);
2019
+
2020
+ //-- scroll to element that matches hash (if have one)
2021
+ //-- this is scrolling the content view
2022
+ if(document.location.hash)
2023
+ {
2024
+ let gotoEle = resolveAnchorElement(document.location.hash);
2025
+ if(gotoEle) GotoAndHighlightAnchorContent(gotoEle,false); //-- hash present = user targeted a section, so always run the highlight animation
2026
+ }
2027
+ else if(!bInitialPageLoad)
2028
+ {
2029
+ //-- no target anchor: present the new page from the top. The article is swapped
2030
+ //-- inside the SAME scroll container, so the previous page's scroll offset
2031
+ //-- survived the swap and in-book navigation showed new pages part-scrolled-down.
2032
+ //-- Skipped on initial load/refresh - the browser owns scroll restoration there. //-- SG
2033
+ let scrollableParent = getScrollParent(document.getElementById("DocContent"));
2034
+ if(scrollableParent) scrollableParent.scrollTop = 0;
2035
+ }
2036
+
2037
+ bInitialPageLoad = false;
2038
+ };
2039
+
2040
+ //-- make sure selected link parents are set to .expand=true, then ALWAYS wait for Vue to
2041
+ //-- flush before finishRender runs.
2042
+ //--
2043
+ //-- This used to wait only when a nav group had expanded, and go straight through
2044
+ //-- otherwise. That was safe while everything finishRender depends on was applied by a
2045
+ //-- synchronous DOM write. It is not any more: the content reveal (contentLoading) and
2046
+ //-- the layout class above are both reactive now, and both land on the next tick -
2047
+ //-- while finishRender scrolls to an anchor and measures the section it highlights.
2048
+ //-- Measuring before the layout class applies means measuring against the wrong layout.
2049
+ //-- Waiting unconditionally costs one tick and removes the whole class of problem.
2050
+ //-- //-- SG
2051
+ expandNavParentSectionBySelectedLinkHref(linkRef);
2052
+ view.$nextTick(finishRender);
161
2053
 
162
- //--- GLOBAL FUNCTIONS
163
- function removeTrailingSlash(str) {
164
- return str.endsWith("/") ? str.slice(0, -1) : str;
165
2054
  }
166
- function removeStartingSlash(str) {
167
- return str.startsWith("/") ? str.slice(1, str.length) : str;
168
2055
 
169
- //if(linkRef.indexOf("/")===0)linkRef = linkRef.replace("/","");
2056
+ //-- return t/f is the element is actually visible on the screen
2057
+ function elementIsOnVisibleScreen(oEle)
2058
+ {
2059
+
2060
+ const item = oEle.getBoundingClientRect();
2061
+ return (
2062
+ item.top >= 0 &&
2063
+ item.left >= 0 &&
2064
+ item.bottom <= (
2065
+ window.innerHeight ||
2066
+ document.documentElement.clientHeight) &&
2067
+ item.right <= (
2068
+ window.innerWidth ||
2069
+ document.documentElement.clientWidth)
2070
+ );
170
2071
  }
171
2072
 
172
- function toSeoUrl(url) {
173
- return (
174
- url
175
- .toString() // Convert to string
176
- .normalize("NFD") // Change diacritics
177
- // biome-ignore lint/suspicious/noMisleadingCharacterClass: Ignoring this as it's required
178
- .replace(/[\u0300-\u036f]/g, "") // Remove illegal characters
179
- .replace(/\s+/g, "-") // Change whitespace to dashes
180
- .toLowerCase() // Change to lowercase
181
- .replace(/&/g, "-and-") // Replace ampersand
182
- .replace(/[^a-z0-9\-]/g, "") // Remove anything that is not a letter, number or dash
183
- .replace(/-+/g, "-") // Remove duplicate dashes
184
- .replace(/^-*/, "") // Remove starting dashes
185
- .replace(/-*$/, "")
186
- ); // Remove trailing dashes
187
- }
188
-
189
- function listenForHrefClicks() {
190
- //-- trap all link click events - we want to handle links so can cancel and load content ourselves
191
- $("A")
192
- .off("click")
193
- .on("click", function (ev) {
194
- if (this.href) {
195
- const checkUrl = new URL(this.href);
196
-
197
- view.resetMobileMenu();
198
-
199
- //-- clicking on a hash tag header link element - so jsut scroll to element
200
- if (`${checkUrl.origin}/` === siteBaseLocation) {
201
- //-- if a url that is part of this doc site then handle loading content inline else let link do whatever (i.e. its an external link)
202
- ev.stopImmediatePropagation();
203
- ev.preventDefault();
204
-
205
- //-- do we need to scroll into view element on this page
206
- if (
207
- checkUrl.hash &&
208
- getAnchorFromHash(checkUrl.hash, "faq-toc-item")
209
- ) {
210
- const eleAnc = getAnchorFromHash(checkUrl.hash, "faq-toc-item");
211
- if (eleAnc[0]?.scrollIntoView) eleAnc[0].scrollIntoView();
212
-
213
- if (checkUrl.pathname === "/") {
214
- //-- going to element on current page so just update current browser url with new #
215
- const currUrl = new URL(document.location.href);
216
- currUrl.hash = checkUrl.hash;
217
- if (currUrl.href !== document.location.href)
218
- window.history.pushState(null, null, currUrl); //-- user has clicked a link so we want to add state to history so we can click back
219
- } else if (ele.href !== document.location.href) {
220
- window.history.pushState(null, null, this.href); //-- user has clicked a link so we want to add state to history so we can click back
221
- }
222
- } else {
223
- loadContentUrl(this.getAttribute("href"), false, false);
224
- }
225
- return false;
226
- }
227
- //-- if not part of this website then always open in new tab as considered an external site
228
- this.setAttribute("target", "_blank");
229
- }
230
- });
2073
+ function getScrollParent(node) {
2074
+ if (!node) {
2075
+ return null;
2076
+ }
2077
+
2078
+ if (node.scrollHeight > node.clientHeight) {
2079
+ return node;
2080
+ } else {
2081
+ return getScrollParent(node.parentNode);
2082
+ }
2083
+ }
2084
+
2085
+ //-- pending teardown for the section-highlight overlay (item 23) //-- SG
2086
+ let sectionHighlightTimer = null;
2087
+
2088
+ //-- Remove the section-highlight overlay, if one is up. Safe to call at any time. //-- SG
2089
+ function removeSectionHighlightOverlay()
2090
+ {
2091
+ if(sectionHighlightTimer)
2092
+ {
2093
+ clearTimeout(sectionHighlightTimer);
2094
+ sectionHighlightTimer = null;
2095
+ }
2096
+ document.querySelectorAll(".hb-section-highlight").forEach(function(el){ el.remove(); });
231
2097
  }
232
2098
 
233
- //-- return t/f is the element is actually visible on the screen
234
- function elementIsOnVisibleScreen(oEle) {
235
- const item = oEle.getBoundingClientRect();
236
- return (
237
- item.top >= 0 &&
238
- item.left >= 0 &&
239
- item.bottom <=
240
- (window.innerHeight || document.documentElement.clientHeight) &&
241
- item.right <= (window.innerWidth || document.documentElement.clientWidth)
242
- );
2099
+ function GotoAndHighlightAnchorContent(currentActiveAnchor,boolScrollToOnly)
2100
+ {
2101
+ //-- remove any current highlighting
2102
+ removeSectionHighlightOverlay();
2103
+
2104
+ if(currentActiveAnchor && currentActiveAnchor.scrollIntoView){
2105
+
2106
+ //-- adjust scroll point for anchor so is not cut off at the top.
2107
+ //-- offsetTop was used here and is measured from the nearest POSITIONED ancestor, not
2108
+ //-- from the scroll container, so any heading inside a positioned wrapper (a video
2109
+ //-- embed, a layout div, a component) scrolled to the wrong place - usually to the top
2110
+ //-- of the document. Measure the real delta between the two boxes instead. //-- SG
2111
+ let scrollableParent = getScrollParent(document.getElementById("DocContent"));
2112
+ if(scrollableParent)
2113
+ {
2114
+ //-- a root scroller (documentElement/body) has no useful client rect of its own -
2115
+ //-- its content box origin IS the viewport origin, so the offset to compare against is 0
2116
+ let boolRootScroller = (scrollableParent === document.documentElement || scrollableParent === document.body);
2117
+ let containerTop = boolRootScroller ? 0 : (scrollableParent.getBoundingClientRect().top + scrollableParent.clientTop);
2118
+ let deltaToTop = currentActiveAnchor.getBoundingClientRect().top - containerTop;
2119
+ scrollableParent.scrollTop += deltaToTop - 30;
2120
+ }
2121
+ else
2122
+ {
2123
+ currentActiveAnchor.scrollIntoView({block:"center"});
2124
+ }
2125
+
2126
+ if(boolScrollToOnly) return; //-- only want to scroll to
2127
+
2128
+ //-- highlight the section for any heading (H1-H6): the heading plus everything up to the
2129
+ //-- next heading of the same-or-higher level.
2130
+ //--
2131
+ //-- This used to WRAP those elements in a .highlight-container div and animate that. The
2132
+ //-- wrapper was never unwrapped (the timeout that did it was commented out), so every
2133
+ //-- hash click wrapped again - containers accumulated and nested, the sibling walk for
2134
+ //-- later clicks swallowed earlier containers whole, the section crept 2px left per
2135
+ //-- trigger, and docApp.tableOfContents[].ele ended up pointing at relocated nodes.
2136
+ //--
2137
+ //-- Now nothing in the article is moved or re-parented at all: we measure the section's
2138
+ //-- bounding box and draw one absolutely-positioned overlay over it, inside
2139
+ //-- .injected-document-container (already position:relative, in both the main and inline
2140
+ //-- shells). The overlay is pointer-events:none and removes itself. //-- SG
2141
+ let headingMatch = /^H([1-6])$/.exec(currentActiveAnchor.tagName || "");
2142
+ if(headingMatch)
2143
+ {
2144
+ let currentLevel = parseInt(headingMatch[1], 10);
2145
+ //-- loop next siblings - stop at the next heading of same-or-higher level (or end of collection)
2146
+ let arrSectionElements = [currentActiveAnchor];
2147
+ let nextSibling = currentActiveAnchor.nextElementSibling;
2148
+ while(nextSibling)
2149
+ {
2150
+ //-- exit - a heading of same-or-higher level starts a new section, so stop here
2151
+ let siblingMatch = /^H([1-6])$/.exec(nextSibling.tagName || "");
2152
+ if(siblingMatch && parseInt(siblingMatch[1], 10) <= currentLevel) break;
2153
+ arrSectionElements.push(nextSibling);
2154
+ nextSibling = nextSibling.nextElementSibling;
2155
+ }
2156
+
2157
+ if(arrSectionElements.length > 1) showSectionHighlightOverlay(arrSectionElements,currentActiveAnchor.tagName);
2158
+ }
2159
+ }
243
2160
  }
244
2161
 
245
- //-- based on content url highlight match navigation menu item
246
- function highlightNavigationLinkFromUrl(matchLinkHref) {
247
- const clean_matchLinkHref = removeStartingSlash(matchLinkHref).split("#")[0]; //-- remvoe # link
248
- $("a.DocLink").removeClass("active");
249
-
250
- const arrItems = document.querySelectorAll("a.DocLink");
251
-
252
- for (let x = 0; x < arrItems.length; x++) {
253
- const el = arrItems[x];
254
- let checkUrl = null;
255
- try {
256
- checkUrl = new URL(el.href);
257
- } catch (e) {
258
- //hslDocLog.log("BAD navigation menu item found",el);
259
- }
260
-
261
- if (checkUrl) {
262
- const testCurrPath = removeStartingSlash(checkUrl.pathname);
263
- if (
264
- testCurrPath === clean_matchLinkHref ||
265
- testCurrPath.indexOf(clean_matchLinkHref) === 0 ||
266
- clean_matchLinkHref.indexOf(
267
- `${removeStartingSlash(checkUrl.pathname)}/`,
268
- ) === 0
269
- ) {
270
- //-- exact match
271
- $(el).addClass("active");
272
- if (!elementIsOnVisibleScreen(el)) el.scrollIntoView();
273
- return false; //break out
274
- }
275
- }
276
- }
2162
+ //-- Draw the highlight box over a section without touching the article DOM. //-- SG
2163
+ function showSectionHighlightOverlay(arrSectionElements,strHeadingTag)
2164
+ {
2165
+ let overlayHost = document.querySelector(".injected-document-container");
2166
+ if(!overlayHost) return;
2167
+
2168
+ //-- union of the section's boxes, expressed in the host's coordinate space
2169
+ let hostRect = overlayHost.getBoundingClientRect();
2170
+ let top = null, bottom = null;
2171
+ arrSectionElements.forEach(function(ele)
2172
+ {
2173
+ let rect = ele.getBoundingClientRect();
2174
+ if(!rect.height && !rect.width) return; //-- skip anything not laid out
2175
+ if(top === null || rect.top < top) top = rect.top;
2176
+ if(bottom === null || rect.bottom > bottom) bottom = rect.bottom;
2177
+ });
2178
+ if(top === null) return;
2179
+
2180
+ let overlay = document.createElement("div");
2181
+ overlay.className = "hb-section-highlight " + strHeadingTag;
2182
+ overlay.style.top = (top - hostRect.top + overlayHost.scrollTop - 4) + "px";
2183
+ overlay.style.height = (bottom - top + 8) + "px";
2184
+ overlayHost.appendChild(overlay);
2185
+
2186
+ //-- tear down after the animation. animationend is the accurate signal; the timeout is the
2187
+ //-- fallback for a hidden tab or a browser that skips the animation entirely.
2188
+ let cleanup = function(){ removeSectionHighlightOverlay(); };
2189
+ overlay.addEventListener("animationend",cleanup,{once:true});
2190
+ sectionHighlightTimer = setTimeout(cleanup,5000);
277
2191
  }
278
2192
 
279
- function getAnchorFromHash(strHash, strHasClass) {
280
- try {
281
- let strSelector = toSeoUrl(strHash.replace("#", ""));
282
- if (strHasClass) strSelector += `.${strHasClass}`;
2193
+ async function loadContentUrl(linkRef,fromPageRefresh,fromPopState)
2194
+ {
2195
+ //-- empty vue enabled doc content
2196
+ let vueBody = vueContentContainer();
2197
+ if(vueBody) vueBody.innerHTML = "";
2198
+
2199
+ //-- the section-highlight overlay is a sibling of the content, so emptying the content does
2200
+ //-- not take it with it - drop it here or it hangs over the next document. //-- SG
2201
+ removeSectionHighlightOverlay();
2202
+
2203
+
2204
+ //-- reset search mode
2205
+ view.docApp.searchMode = false;
2206
+ view.docApp.mcpCatalogMode = false;
2207
+ view.docApp.searchResponse = null;
2208
+
2209
+ //-- clear table of contents
2210
+ view.docApp.tableOfContents = [];
2211
+ view.docApp.tocMobileOpen = false;
2212
+ //-- hide footer while loading
2213
+ view.docApp.contentLoading = true;
2214
+
2215
+ if(linkRef.indexOf(window.location.origin +"/")===0)
2216
+ {
2217
+ linkRef = linkRef.replace(window.location.origin +"/","");
2218
+ }
2219
+
2220
+ //-- remove trailing / if has one
2221
+ linkRef = removeTrailingSlash(linkRef);
2222
+ //-- remove starting / if link starts with it
2223
+ linkRef = removeStartingSlash(linkRef);
2224
+
2225
+ let frontmatterData = {};
2226
+ let urlInfo = linkRef.split("#"); //-- remove data hash
2227
+ let hash = (urlInfo[1])?"#"+urlInfo[1] : "";
2228
+
2229
+ linkRef = urlInfo[0];
2230
+ let booksLinkRef = linkRef.includes('_books/') ? linkRef : "_books/"+linkRef;
2231
+
2232
+ //-- coming for initial browser load or refresh - so make sure have content load (which the http server should have already injected)
2233
+ let initialBody = contentContainer();
2234
+ if(bInitialPageLoad && initialBody && initialBody.textContent)
2235
+ {
2236
+ //-- content is already loaded - but we need to get the front matter data (for layout etc).
2237
+ //-- NOTE: assign to the OUTER frontmatterData - a shadowing `let` here used to mean the
2238
+ //-- meta-absent case passed "" (getMeta's miss value) into postProcessBookContentRender
2239
+ //-- instead of the normalised {} declared above. //-- SG
2240
+ let metaFrontmatter = getMeta("hdoc:frontmatter");
2241
+ if(metaFrontmatter)
2242
+ {
2243
+ frontmatterData = JSON.parse(atob(metaFrontmatter));
2244
+ let baseUrl = removeTrailingSlash(window.location.origin);
2245
+ let bookId = window.location.href.split(baseUrl+"/")[1].split("/")[0];
2246
+
2247
+ frontmatterData.formattedCommitDate = new Date(frontmatterData["last-commit"]).toLocaleDateString();
2248
+ frontmatterData.readTime = frontmatterData["reading-time"] + " minute to read";
2249
+ frontmatterData.contributorCount = frontmatterData["contributor-count"]-0;
2250
+
2251
+ view.docApp.book = frontmatterData;
2252
+ view.docApp.book.docId = bookId;
2253
+ view.docApp.inlineTitle =frontmatterData.title;
2254
+
2255
+ // fetch hdocbook.json to get book level fields (e.g. version) that are missing from frontmatter header
2256
+ fetchJsonFile(`_books/${bookId}/hdocbook.json`).then((hdocbook) => {
2257
+ if (hdocbook) {
2258
+ view.docApp.book = Object.assign({}, hdocbook, view.docApp.book);
2259
+ //-- the title band binds docApp.title/description, which only loadLibraryBook
2260
+ //-- used to set - so on a direct page load / refresh it rendered empty //-- SG
2261
+ view.docApp.title = hdocbook.title || "";
2262
+ view.docApp.description = hdocbook.description || "";
2263
+ }
2264
+ });
2265
+ }
2266
+
2267
+ let buildData = getMeta("hdoc:build");
2268
+ if(buildData)
2269
+ {
2270
+ view.docApp.build = buildData;
2271
+ }
2272
+
2273
+ let nodeData = getMeta("hdoc:node");
2274
+ if(nodeData)
2275
+ {
2276
+ view.docApp.node = nodeData.split(".")[0];
2277
+ }
2278
+
2279
+ //-- load the book info json
2280
+ view.docApp.bookInfo = null;
2281
+ let jsonFileUrl = booksLinkRef+ ".info";
2282
+ view.docApp.bookInfo = await fetchJsonFile(jsonFileUrl);
2283
+ if(!view.docApp.bookInfo)view.docApp.bookInfo = null;
2284
+
2285
+ view.docApp.frontmatterData = frontmatterData;
2286
+ postProcessBookContentRender(linkRef+hash,frontmatterData,booksLinkRef);
2287
+ }
2288
+ else
2289
+ {
2290
+ //-- we are doing a content load from A tag link or back btn - so ftch content and inject mark up
2291
+ //-- update url in browser
2292
+ setBrowserViewUrl(linkRef+hash,{},fromPageRefresh);
2293
+
2294
+ //-- destroy any existing content children and events properly
2295
+ let clearBody = contentContainer();
2296
+ if(clearBody) clearBody.innerHTML = "";
2297
+
2298
+ //-- Fire the .info JSON and the content HTML together instead of awaiting .info
2299
+ //-- first. They are independent requests, so serialising them cost a full extra
2300
+ //-- round-trip on every in-book navigation. postProcessBookContentRender still
2301
+ //-- needs bookInfo (addPdfIconToHeader reads it), so we join both before
2302
+ //-- injecting and the ordering it depends on is unchanged. //-- SG
2303
+ view.docApp.bookInfo = null;
2304
+
2305
+ //-- for now just load whatever the href prop is (we can add actions to perform specific processing and then load content based on element atts etc)
2306
+ view.docApp.build = 0;
2307
+ view.docApp.node = "N/A";
2308
+
2309
+ let infoPromise = fetchJsonFile(booksLinkRef + ".info");
2310
+ let contentPromise = fetch(booksLinkRef).then(response =>
2311
+ {
2312
+ //-- the server 301s renamed pages (book-declared redirects) on the _books
2313
+ //-- content endpoint and fetch follows it transparently, so the content is
2314
+ //-- already right - but linkRef/booksLinkRef and the history entry we pushed
2315
+ //-- above still point at the old page. Rewrite them from the final response
2316
+ //-- url so nav highlight, pdf link and the address bar show the new page.
2317
+ //-- replaceState (not push) - the old-url entry is dead, back should skip it. //-- SG
2318
+ if(response.redirected && response.url)
2319
+ {
2320
+ let redirectedPath = removeStartingSlash(new URL(response.url).pathname);
2321
+ if(redirectedPath.indexOf("_books/") === 0)
2322
+ {
2323
+ booksLinkRef = redirectedPath;
2324
+ linkRef = redirectedPath.substring("_books/".length);
2325
+ window.history.replaceState(null, null, window.location.origin + "/" + linkRef + hash);
2326
+ }
2327
+ }
2328
+
2329
+ if(response.headers.has("X-esp-node"))
2330
+ {
2331
+ view.docApp.node = response.headers.get("X-esp-node").split(".")[0];
2332
+ }
2333
+
2334
+ if(response.headers.has("X-esp-build"))
2335
+ {
2336
+ view.docApp.build = response.headers.get("X-esp-build");
2337
+ }
2338
+ if(response.headers.has("X-frontmatter"))
2339
+ {
2340
+ frontmatterData = response.headers.get("X-frontmatter");
2341
+ frontmatterData = JSON.parse(atob(frontmatterData));
2342
+ view.docApp.frontmatterData = frontmatterData;
2343
+ } else {
2344
+ //-- the fallback path used to parse into the local only and never write it back
2345
+ //-- to docApp, so the previous page's frontmatter stayed live - addPdfIconToHeader
2346
+ //-- reads docApp.frontmatterData['pdf-path'] and served the wrong PDF. getMeta
2347
+ //-- returns "" when the tag is absent, so normalise to an object. //-- SG
2348
+ let metaFrontmatter = getMeta("hdoc:frontmatter");
2349
+ frontmatterData = metaFrontmatter ? JSON.parse(atob(metaFrontmatter)) : {};
2350
+ view.docApp.frontmatterData = frontmatterData;
2351
+ }
283
2352
 
284
- const jqEle = $(`#${strSelector}`);
285
- if (jqEle[0]) {
286
- return jqEle;
287
- }
288
- return null;
289
- } catch (e) {
290
- hslDocLog.log(`Get document anchor element by hash failed [${strHash}]`, e);
291
- return null;
292
- }
2353
+ return response.text();
2354
+ });
2355
+
2356
+ try
2357
+ {
2358
+ let contentResults = await Promise.all([infoPromise,contentPromise]);
2359
+ view.docApp.bookInfo = contentResults[0] || null;
2360
+ let injectBody = contentContainer();
2361
+ if(injectBody) injectBody.innerHTML = contentResults[1]; //-- inject content if not from initial load
2362
+ postProcessBookContentRender(linkRef+hash,frontmatterData,booksLinkRef);
2363
+ }
2364
+ catch(e)
2365
+ {
2366
+ //-- previously an unhandled rejection - log it rather than failing silently. Same
2367
+ //-- visible outcome as before: nothing injected, no post-processing. //-- SG
2368
+ hslDocLog.log("Failed to load content for ["+booksLinkRef+"]",e);
2369
+ }
2370
+ }
2371
+ //console.log(view.docApp)
293
2372
  }
294
2373
 
295
- //-- create items to stick in toc from current loaded content
296
- //-- searches for H2,H3
297
- function generateTableOfContentsFromDoc() {
298
- const container = jqContentContainer[0];
299
- if (container) {
300
- const currUrl = new URL(document.location.href);
301
- view.docApp.tableOfContents = [];
302
- for (let i = 2; i <= 3; i++) {
303
- const headers = container.getElementsByTagName(`h${i}`);
304
- for (let j = 0; j < headers.length; j++) {
305
- const jqCurrHeader = $(headers[j]);
306
- //-- only process headers that do not have this class
307
- if (!jqCurrHeader.hasClass("no-auto-anchor"))
308
- jqCurrHeader.addClass("faq-toc-item");
309
- }
310
- }
311
-
312
- const headersTocItem = container.getElementsByClassName("faq-toc-item");
313
- for (let k = 0; k < headersTocItem.length; k++) {
314
- const item = headersTocItem[k];
315
- const strLabel = $.trim(item.innerText);
316
- if (strLabel) {
317
- const seoSafeId = toSeoUrl(strLabel); //-- will use SEO to set in url so can scroll to element from url
318
- const linkUrl = `${currUrl.pathname}#${seoSafeId}`;
319
- item.id = seoSafeId;
320
-
321
- view.docApp.tableOfContents.push({
322
- ele: item,
323
- eleText: strLabel,
324
- tagName: item.tagName,
325
- href: linkUrl,
326
- });
327
-
328
- //-- add link icon to header (using seo id)
329
- $(item).append(
330
- `<a class="header-anchor" aria-label="Link to this section: ${strLabel}"><i class="bi bi-link" aria-hidden="true"></i></a>`,
331
- );
332
- $(item).find("a").attr("href", linkUrl);
333
- }
334
- }
335
- }
336
- }
337
-
338
- function loadContentUrl(
339
- linkRef,
340
- fromPageRefresh,
341
- fromPopState,
342
- fromBook = true,
343
- ) {
344
- let downloadFile = false;
345
- if (linkRef.includes('_books/')) {
346
- downloadFile = linkRef.split('').length > 1;
347
- console.log('downloadFile', downloadFile);
348
- }
349
-
350
- //-- clear table of contents
351
- view.docApp.tableOfContents = [];
352
- let clean_linkRef = linkRef;
353
- if (clean_linkRef.indexOf(`${window.location.origin}/`) === 0) {
354
- clean_linkRef = clean_linkRef.replace(`${window.location.origin}/`, "");
355
- }
356
-
357
- //-- remove trailing / if has one
358
- clean_linkRef = removeTrailingSlash(clean_linkRef);
359
- //-- remove starting / if link starts with it
360
- clean_linkRef = removeStartingSlash(clean_linkRef);
361
-
362
- let booksLinkRef = clean_linkRef;
363
- if (fromBook) {
364
- booksLinkRef = clean_linkRef.includes("_books/")
365
- ? clean_linkRef
366
- : `_books/${clean_linkRef}`;
367
- }
368
-
369
- if (downloadFile) {
370
- //-- open in new tab to download
371
- window.open(booksLinkRef, '_blank');
372
- } else {
373
- //-- update url in browser
374
- if (fromBook) {
375
- setBrowserViewUrl(clean_linkRef, {}, fromPageRefresh, fromPopState);
376
- }
377
-
378
- //-- destroy any existing content children and events properly
379
- jqContentContainer.empty();
380
- jqDocumentHeader.find(".document-title").empty();
381
-
382
- //-- for now just load whatever the href prop is (we can add actions to perform specific processing and then load content based on element atts etc)
383
- let frontmatterData = {};
384
- fetch(booksLinkRef)
385
- .then((response) => {
386
- if (response.headers.has("X-frontmatter")) {
387
- frontmatterData = response.headers.get("X-frontmatter");
388
- frontmatterData = JSON.parse(atob(frontmatterData));
389
- }
390
- return response.text();
391
- })
392
- .then((html) => {
393
- jqContentContainer.html(html);
394
-
395
- //-- remove previous layoutclass if have one
396
- if (global.lastLayoutClass) {
397
- $("#DocContent").removeClass(global.lastLayoutClass);
398
- global.lastLayoutClass = "";
2374
+ function addPdfIconToHeader(direct_BooksLinkRef)
2375
+ {
2376
+ //-- do we have a pdf
2377
+ if(view.docApp.bookInfo && view.docApp.bookInfo.pdf_size)
2378
+ {
2379
+ let tooltipSize = "Download (" + formatBytes(view.docApp.bookInfo.pdf_size) +")";
2380
+ let bodyForToolbar = contentContainer();
2381
+ let tbRight = bodyForToolbar ? bodyForToolbar.querySelector(".toolbar-right") : null;
2382
+ if(tbRight)
2383
+ {
2384
+ let pdfUrl = direct_BooksLinkRef + ".pdf";
2385
+ if (view.docApp.frontmatterData['pdf-path'] && view.docApp.frontmatterData['pdf-path'] !== '') {
2386
+ pdfUrl = '/_books/' + view.docApp.frontmatterData['pdf-path'];
399
2387
  }
2388
+
2389
+ if(!tbRight.querySelector(".pdffile"))
2390
+ {
2391
+ tbRight.insertAdjacentHTML("afterbegin",`<span class="toolbar-action pdffile me-1" ><a target="_blank" title="${tooltipSize}" href="${pdfUrl}" download rel="nofollow"><span>Download</span><span>PDF</span></a></span>`);
2392
+ }
2393
+ }
2394
+ }
2395
+ }
400
2396
 
401
- //-- if have layoutclass add it to content container - this is so we can specify table list view sizes etc
402
- if (frontmatterData.layout) {
403
- $("#DocContent").addClass(frontmatterData.layout);
404
- global.lastLayoutClass = frontmatterData.layout;
405
- }
2397
+ function findFirstClickableBookLink(findByLinkRef,currentNavSection)
2398
+ {
2399
+ if(currentNavSection === undefined)
2400
+ currentNavSection = view.docApp.navSections;
2401
+
2402
+
2403
+ for(let x=0;x<currentNavSection.length;x++)
2404
+ {
2405
+ if(currentNavSection[x].items)
2406
+ {
2407
+ let clickableItem = findFirstClickableBookLink(findByLinkRef,currentNavSection[x].items);
2408
+ if(clickableItem)
2409
+ {
2410
+ return clickableItem;
2411
+ }
2412
+ }
2413
+ else if(currentNavSection[x].link)
2414
+ {
2415
+ if(findByLinkRef && findByLinkRef.indexOf("/")>-1)
2416
+ {
2417
+ if(removeStartingSlash(removeTrailingSlash(currentNavSection[x].link)) == findByLinkRef) return currentNavSection[x].link;
2418
+ }
2419
+ else
2420
+ {
2421
+ return currentNavSection[x].link;
2422
+ }
2423
+ }
2424
+ }
2425
+ }
406
2426
 
407
- //--generate toc for all layouts that have h2/h3
408
- view.docApp.keepTocLayout = frontmatterData.layout === "article-no-toc";
409
- if (
410
- !frontmatterData.layout ||
411
- frontmatterData.layout === "article" ||
412
- frontmatterData.layout === "article-toc"
413
- ) {
414
- view.docApp.keepTocLayout = true;
415
- generateTableOfContentsFromDoc();
416
- }
2427
+ function expandNavParentSectionBySelectedLinkHref(findByLinkRef, currentNavSection)
2428
+ {
2429
+ findByLinkRef = findByLinkRef.split("#")[0];
2430
+ if(currentNavSection === undefined)
2431
+ {
2432
+ currentNavSection = view.docApp.navSections;
2433
+ }
2434
+
2435
+ for(let x=0;x<currentNavSection.length;x++)
2436
+ {
2437
+ if(currentNavSection[x].items)
2438
+ {
2439
+ let res = expandNavParentSectionBySelectedLinkHref(findByLinkRef,currentNavSection[x].items);
2440
+ if(res)
2441
+ {
2442
+ currentNavSection[x].expand=true;
2443
+ return res;
2444
+ }
2445
+ }
2446
+ else if(currentNavSection[x].link)
2447
+ {
2448
+ //-- check item to see if it matches url. The .link guard matches the one in
2449
+ //-- findFirstClickableBookLink - a nav leaf with no link used to throw here. //-- SG
2450
+ if(removeStartingSlash(removeTrailingSlash(currentNavSection[x].link)) == findByLinkRef)
2451
+ {
2452
+ return true;
2453
+ }
2454
+ }
2455
+ }
417
2456
 
418
- //-- find any <tabs> and andd a bootstrap tab item strip
419
- $("#DocContent")
420
- .find("tabs")
421
- .each((idx, el) => {
422
- const tabClass = el.getAttribute("tabstyle");
423
- let tabMarkup = "<ul class='nav nav-tabs'>";
424
- //-- for each tab we need to create a tab link
425
- $(el)
426
- .find("tab")
427
- .each((idx, aTab) => {
428
- const activeClass = idx === 0 ? " active" : "";
429
- tabMarkup += `<li class='nav-item c-pointer' contentidx='${idx}'><span class='nav-link${activeClass}'>${aTab.getAttribute("name")}</span></li>`;
430
-
431
- //-- hide the tab element if it is not the active one (first)
432
- if (idx > 0) $(aTab).addClass("d-none");
433
- });
434
-
435
- tabMarkup += "</ul>";
436
- //-- inject markup
437
- $(el).prepend(tabMarkup).find(".nav.nav-tabs").addClass(tabClass);
438
-
439
- //-- add event handler for switching tab items
440
- $(el)
441
- .find("li")
442
- .off("click")
443
- .on("click", function () {
444
- const contentIdx = this.getAttribute("contentidx");
445
- const tabsContainer = $(this).closest("tabs").eq(0);
446
- const tabs = tabsContainer.find("tab");
447
-
448
- //-- hide other tab content and show this tabs content
449
- tabs.addClass("d-none");
450
- tabs.eq(contentIdx - 0).removeClass("d-none");
451
-
452
- //-- set clicked tab item to active
453
- tabsContainer.find(".nav-link").removeClass("active");
454
- $(this).find(".nav-link").addClass("active");
455
- });
456
- });
457
-
458
- //-- do any code highlighting
459
- if (hljs) {
460
- for (const el of document.querySelectorAll("pre code")) {
461
- hljs.highlightBlock(el);
462
- }
463
- } else {
464
- hslDocLog.log(
465
- "code highlighting js could not be loaded. code block highlighting will not be enabled",
466
- );
467
- }
2457
+ }
2458
+
2459
+ function setBrowserViewUrl(strHrefToLoad,viewData,fromPageRefresh)
2460
+ {
2461
+
2462
+ let urlToSet = window.location.origin + "/" + strHrefToLoad;
2463
+ const url = new URL(urlToSet);
2464
+
2465
+ //-- remove any old params
2466
+ for(let strKey in global.stateParams)
2467
+ {
2468
+ url.searchParams.delete(strKey);
2469
+ }
2470
+
2471
+ //-- now set new data params in url
2472
+ for(let strKey in viewData)
2473
+ {
2474
+ url.searchParams.set(strKey,viewData[strKey]);
2475
+ }
2476
+
2477
+ //-- store global params
2478
+ global.stateParams = viewData;
2479
+
2480
+ //-- if havent clicked back or forwards, and this isn't a refresh/first paint (where the
2481
+ //-- browser already holds the right entry), record the navigation. Push only when the url
2482
+ //-- actually changes, compared against the LIVE location - the old "lastUrlDynamicallySet"
2483
+ //-- string went stale after back-button trips (home -> book -> back -> back -> same book
2484
+ //-- again matched the stale value and replaceState'd over the home entry, so the second
2485
+ //-- back skipped the book page). Same-url re-clicks now simply do nothing: the entry the
2486
+ //-- user is on is already correct. //-- SG
2487
+ if(!currentPopState && !fromPageRefresh)
2488
+ {
2489
+ if(url.href !== window.location.href)
2490
+ {
2491
+ window.history.pushState(null, null, url); //-- add entry
2492
+ }
2493
+ }
2494
+ currentPopState = null;
2495
+ }
468
2496
 
469
- //-- render any Mermaid diagrams client-side (lazy-loads the bundle)
470
- hbRenderMermaidDiagrams();
471
-
472
- //-- show copy button by any code blocks
473
- const options = {
474
- contentSelector: ".injected-document-content",
475
- // Delay in ms used for `setTimeout` before badging is applied
476
- // Use if you need to time highlighting and badge application
477
- // since the badges need to be applied afterwards.
478
- // 0 - direct execution (ie. you handle timing
479
- loadDelay: 0,
480
-
481
- // CSS class(es) used to render the copy icon.
482
- copyIconClass: "bi bi-clipboard",
483
- // CSS class(es) used to render the done icon.
484
- checkIconClass: "bi bi-clipboard-check --htl-c-brand",
485
-
486
- // intercept text copying - passed in text return text out
487
- onBeforeCodeCopied: (text) => text,
488
- };
489
-
490
- highlightJsBadge(options);
491
-
492
- //-- get first H1 and put it into our header section header
493
- const docHeader = jqContentContainer.find("H1").eq(0);
494
- jqDocumentHeader.find(".document-title").append(docHeader);
495
-
496
- setGitHubBookUrl(clean_linkRef);
497
-
498
- //-- make sure selected link parents are set to .expand=true
499
- if (expandNavParentSectionBySelectedLinkHref(clean_linkRef)) {
500
- view.updateCounter++;
501
- view.$nextTick(() => {
502
- //-- find any navigation links that match url and highlight
503
- // listenForHrefClicks();
504
- highlightNavigationLinkFromUrl(clean_linkRef);
505
-
506
- //-- scroll to element that match hash (if have one)
507
- if (document.location.hash) {
508
- const gotoEle = getAnchorFromHash(document.location.hash);
509
- if (gotoEle) gotoEle[0].scrollIntoView();
510
- }
511
- });
512
- view.$forceUpdate();
513
- }
514
2497
 
515
- listenForHrefClicks();
516
- });
517
- }
518
-
2498
+ //-- Decide whether the content-type filter has dead-ended the search.
2499
+ //--
2500
+ //-- The filter defaults to Documentation because reference material is ~85% of the
2501
+ //-- corpus. That is right for most searches and wrong for some: "userGetInfo" has no
2502
+ //-- documentation answer at all. Without this the default would turn a findable result
2503
+ //-- into a blank page, which is worse than the noise it was added to remove.
2504
+ //--
2505
+ //-- One outcome: shown === 0 -> widen automatically and say why. Never show an empty
2506
+ //-- page when the answer exists one filter away. A filter that is merely hiding
2507
+ //-- results is left alone - the Information type facet counts show what is hidden.
2508
+ //--
2509
+ //-- Returns true when the caller should re-run the search. Cannot loop: widening sets
2510
+ //-- the filter to "" and this returns early when no filter is active. //-- SG
2511
+ //-- Set for exactly one evaluation after an automatic widen. The widen triggers a
2512
+ //-- second search, and that response carries no filter - without this the explanation
2513
+ //-- would be cleared before the user ever read it. //-- SG
2514
+ let docTypeAutoWidened = false;
2515
+
2516
+ //-- The query an automatic widen was applied to. A widen is a rescue for the query that
2517
+ //-- needed it, not a new preference, so the next DIFFERENT query goes back to the
2518
+ //-- default. A filter the user picked themselves is left alone - that is an explicit
2519
+ //-- choice and should persist the way any other facet does. //-- SG
2520
+ let docTypeAutoWidenedForQuery = null;
2521
+
2522
+ //-- Content type shown when the user has not chosen otherwise: 0 = Documentation
2523
+ const docTypeDefaultFilter = "0";
2524
+
2525
+ //-- On the INTERNAL site only, the Information type the user last picked is remembered
2526
+ //-- across sessions, so the facet comes back the way they left it. Internal users
2527
+ //-- search the whole corpus (api/db reference included) and re-picking "All" on every
2528
+ //-- visit was pure friction; public visitors keep the Documentation default, which is
2529
+ //-- tuned for them (see the note on searchDocTypeFilter in the data block).
2530
+ //--
2531
+ //-- Stored in localStorage, NOT in the /search url - a pasted search link must keep
2532
+ //-- meaning exactly what its params say, whoever opens it. A dt in the url still wins
2533
+ //-- for that page load, and does not overwrite the stored preference.
2534
+ //--
2535
+ //-- Gated the same way as showAudienceFilter / showSearchMatchBadges: everything that is
2536
+ //-- not the public site, so docs-internal AND the dev boxes behave alike - behaviour that
2537
+ //-- cannot be reproduced on a dev host cannot be tested there either. //-- SG
2538
+ const docTypeFilterPersisted = window.location.hostname !== "docs.hornbill.com";
2539
+ const docTypeFilterStorageKey = "hdocbook-search-doctype";
2540
+
2541
+ //-- Transient "needs two characters" hint by the toolbar search box. Self-clearing: the hint
2542
+ //-- is feedback on one key press, so it must not outlive it and must never need dismissing.
2543
+ //-- Any search that actually runs cancels it. //-- SG
2544
+ let searchTooShortHintTimeoutId = null;
2545
+ function showSearchTooShortHint()
2546
+ {
2547
+ clearTimeout(searchTooShortHintTimeoutId);
2548
+ view.docApp.searchTooShortHint = true;
2549
+ searchTooShortHintTimeoutId = setTimeout(function(){ view.docApp.searchTooShortHint = false; },3500);
2550
+ }
2551
+ function hideSearchTooShortHint()
2552
+ {
2553
+ clearTimeout(searchTooShortHintTimeoutId);
2554
+ if(view.docApp.searchTooShortHint) view.docApp.searchTooShortHint = false;
519
2555
  }
520
2556
 
521
- function setGitHubBookUrl(linkRef) {
522
- let bookRef = linkRef.split(siteBaseLocation);
523
- bookRef = bookRef[1] ? bookRef[1] : bookRef[0];
524
- const bookId = bookRef.split("/")[0];
525
- view.docApp.documentGithubUrl = `https://github.com/Hornbill-Docs/${bookId}/blob/main/${bookRef}.md`;
526
- //--https://github.com/Hornbill-Docs/hdoc-guide/blob/main/hdoc-guide/overview.md
2557
+ //-- localStorage access throws outright in some privacy modes, so every read/write is
2558
+ //-- guarded and a failure just means the plain default //-- SG
2559
+ function docTypeUserDefaultFilter()
2560
+ {
2561
+ if(!docTypeFilterPersisted) return docTypeDefaultFilter;
2562
+ try
2563
+ {
2564
+ let stored = localStorage.getItem(docTypeFilterStorageKey);
2565
+ //-- "" is a real stored choice (All); only absent or malformed falls back
2566
+ if(stored === "" || (typeof stored === "string" && /^[0-9]+$/.test(stored))) return stored;
2567
+ }
2568
+ catch(e){}
2569
+ return docTypeDefaultFilter;
527
2570
  }
528
2571
 
529
- function loadBookDefaultLink() {
530
- $(".DocLink.link").first().click();
2572
+ //-- Only ever called from an explicit user pick. An automatic widen is a rescue for one
2573
+ //-- query, not a preference, so it must never be written here. //-- SG
2574
+ function rememberDocTypeFilter(docType)
2575
+ {
2576
+ if(!docTypeFilterPersisted) return;
2577
+ try { localStorage.setItem(docTypeFilterStorageKey,docType); } catch(e){}
531
2578
  }
532
2579
 
533
- function findFirstClickableBookLink(findByLinkRef, currentNavSection) {
534
- let currNav = currentNavSection;
535
- if (currNav === undefined) currNav = view.docApp.navSections;
2580
+ function evaluateDocTypeRescue()
2581
+ {
2582
+ let app = view.docApp;
2583
+
2584
+ if(docTypeAutoWidened)
2585
+ {
2586
+ docTypeAutoWidened = false;
2587
+ return false;
2588
+ }
2589
+
2590
+ app.searchDocTypeRescue = null;
2591
+
2592
+ let resp = app.searchResponse;
2593
+ if(!resp || !resp.docTypesInfo || !resp.docTypesInfo.length || !app.searchDocTypeFilter) return false;
2594
+
2595
+ let shown = 0;
2596
+ let hidden = 0;
2597
+ let best = null;
2598
+
2599
+ for(let i=0;i<resp.docTypesInfo.length;i++)
2600
+ {
2601
+ let dt = resp.docTypesInfo[i];
2602
+ if(String(dt.docType) === app.searchDocTypeFilter)
2603
+ {
2604
+ shown = dt.foundCount;
2605
+ }
2606
+ else
2607
+ {
2608
+ hidden += dt.foundCount;
2609
+ if(best === null || dt.foundCount > best.foundCount) best = dt;
2610
+ }
2611
+ }
2612
+
2613
+ if(hidden === 0) return false;
2614
+
2615
+ //-- only the dead-end case widens; a filter that is merely hiding results is
2616
+ //-- left alone. The old "Show all N results" offer promised the whole hidden
2617
+ //-- count but the server caps at 300 returned, so it could never deliver -
2618
+ //-- the Information type facet counts already show what is hidden. //-- SG
2619
+ if(shown === 0)
2620
+ {
2621
+ app.searchDocTypeFilter = "";
2622
+ app.searchDocTypeRescue = {auto:true, hidden:hidden, best:best};
2623
+ docTypeAutoWidened = true;
2624
+ docTypeAutoWidenedForQuery = app.altSearchText || app.searchText || "";
2625
+ return true;
2626
+ }
2627
+
2628
+ return false;
2629
+ }
536
2630
 
537
- for (let x = 0; x < currNav.length; x++) {
538
- if (currNav[x].items) {
539
- const clickableItem = findFirstClickableBookLink(
540
- findByLinkRef,
541
- currNav[x].items,
542
- );
543
- if (clickableItem) {
544
- return clickableItem;
545
- }
546
- } else if (currNav[x].link) {
547
- if (findByLinkRef) {
548
- if (
549
- removeStartingSlash(removeTrailingSlash(currNav[x].link)) ===
550
- findByLinkRef
551
- )
552
- return currNav[x].link;
553
- } else {
554
- return currNav[x].link;
555
- }
556
- }
557
- }
558
- }
559
-
560
- function expandNavParentSectionBySelectedLinkHref(
561
- findByLinkRef,
562
- currentNavSection,
563
- ) {
564
- let currNav = currentNavSection;
565
- if (currNav === undefined) {
566
- view.docApp.bookBreadCrumb = []; //-- reset bread crumb
567
- currNav = view.docApp.navSections;
568
- }
569
-
570
- for (let x = 0; x < currNav.length; x++) {
571
- if (currNav[x].items) {
572
- const res = expandNavParentSectionBySelectedLinkHref(
573
- findByLinkRef.split("#")[0],
574
- currNav[x].items,
575
- );
576
- if (res) {
577
- currNav[x].expand = true;
578
- view.docApp.bookBreadCrumb.unshift({
579
- title: currNav[x].text,
580
- link: currNav[x].items[0].link,
581
- });
582
- return res;
583
- }
584
- } else {
585
- //-- check item to see if it matches url
586
- if (
587
- removeStartingSlash(removeTrailingSlash(currNav[x].link)) ===
588
- findByLinkRef.split("#")[0]
589
- ) {
590
- return true;
591
- }
592
- }
593
- }
594
- }
595
-
596
- function old_expandNavParentSectionBySelectedLinkHref(
597
- findByLinkRef,
598
- currentNavSection,
599
- ) {
600
- let currNav = currentNavSection;
601
- if (currNav === undefined) currNav = view.docApp.navSections;
602
-
603
- for (let x = 0; x < currNav.length; x++) {
604
- if (currNav[x].items) {
605
- const res = expandNavParentSectionBySelectedLinkHref(
606
- findByLinkRef.split("#")[0],
607
- currNav[x].items,
608
- );
609
- if (res) {
610
- currNav[x].expand = true;
611
- return res;
612
- }
613
- } else {
614
- //-- check item to see if it matches url
615
- if (
616
- removeStartingSlash(removeTrailingSlash(currNav[x].link)) ===
617
- findByLinkRef.split("#")[0]
618
- ) {
619
- return true;
620
- }
621
- }
622
- }
623
- }
624
-
625
- let lastUrlDynamicallySet = "";
626
- function setBrowserViewUrl(
627
- strHrefToLoad,
628
- viewData,
629
- fromPageRefresh,
630
- fromPopState,
631
- ) {
632
- const urlToSet = `${window.location.origin}/${strHrefToLoad}`;
633
- const url = new URL(urlToSet);
634
-
635
- //-- remove any old params
636
- for (const strKey in global.stateParams) {
637
- url.searchParams.delete(strKey);
638
- }
639
-
640
- //-- now set new data params in url
641
- for (const strKey in viewData) {
642
- url.searchParams.set(strKey, viewData[strKey]);
643
- }
644
-
645
- //-- store global params
646
- global.stateParams = viewData;
647
-
648
- if (!fromPageRefresh) {
649
- if (fromPopState) {
650
- //-- so user has clicked on a link - always add entry
651
- window.history.pushState(null, null, url); //-- add entry
652
- } else {
653
- if (lastUrlDynamicallySet) {
654
- if (lastUrlDynamicallySet !== strHrefToLoad) {
655
- window.history.pushState(null, null, url); //-- add entry
656
- } else {
657
- window.history.replaceState(null, null, url); //-- user has refreshed page or jsut visited page
658
- }
659
- } else {
660
- window.history.replaceState(null, null, url); //-- user has clicked a link so we want to add state to history so we can click back
661
- }
662
- }
663
- }
664
- lastUrlDynamicallySet = strHrefToLoad;
2631
+ //-- Decide whether the bid book filter names a book that does not exist - a mistyped or
2632
+ //-- stale pasted url (`/search?q=spf&bid=esp-confi`). A real book with zero matches keeps
2633
+ //-- its filter and shows as a selected zero-count entry; an unknown id would leave the
2634
+ //-- Books list with NOTHING selected and an unexplained empty result page. Same shape as
2635
+ //-- evaluateDocTypeRescue: clear the filter, say why, re-run once. //-- SG
2636
+ //-- One evaluation of grace after the automatic clear, or the re-run's own response
2637
+ //-- would wipe the explanation before the user read it. //-- SG
2638
+ let bookFilterAutoCleared = false;
2639
+
2640
+ function evaluateBookFilterRescue()
2641
+ {
2642
+ let app = view.docApp;
2643
+
2644
+ if(bookFilterAutoCleared)
2645
+ {
2646
+ bookFilterAutoCleared = false;
2647
+ return false;
2648
+ }
2649
+
2650
+ app.searchBookRescue = null;
2651
+
2652
+ if(!app.searchFilterBySpecificBookId) return false;
2653
+
2654
+ //-- only judge the id against a LOADED library - an empty book list means the
2655
+ //-- library fetch has not landed, not that every id is bad //-- SG
2656
+ if(!view.docApp.library || !Array.isArray(view.docApp.library.books) || !view.docApp.library.books.length) return false;
2657
+
2658
+ if(getBookById(app.searchFilterBySpecificBookId) !== undefined) return false;
2659
+
2660
+ app.searchBookRescue = {bid: app.searchFilterBySpecificBookId};
2661
+ app.searchFilterBySpecificBookId = "";
2662
+ app.searchFilterBySpecificBookTitle = "";
2663
+ bookFilterAutoCleared = true;
2664
+ return true;
2665
+ }
2666
+
2667
+ function loadLibraryOrBookFromCurrentUrl(loadingUrl)
2668
+ {
2669
+
2670
+ //-- check if we are loading base url
2671
+ view.docApp.searchMode=false;
2672
+ view.docApp.mcpCatalogMode = false;
2673
+ view.docApp.searchFilteredProducts={};
2674
+
2675
+ let baseUrl = removeTrailingSlash(window.location.origin);
2676
+ let currentUrl = removeTrailingSlash(loadingUrl || window.location.href);
2677
+ if(currentUrl && currentUrl!==baseUrl && currentUrl.indexOf("/?mode=")===-1)
2678
+ {
2679
+ //-- we are trying to load a book
2680
+ let bookId = window.location.href.split(baseUrl+"/")[1].split("/")[0];
2681
+ if(bookId.indexOf("mcp?")===0 || bookId==="mcp")
2682
+ {
2683
+ //-- /mcp - the MCP server catalog. Mirrors the search branch below,
2684
+ //-- including the currentPopState clear: this branch never reaches
2685
+ //-- setBrowserViewUrl(), which is the only place that consumes and clears
2686
+ //-- it, and a stale value silently suppresses the NEXT link click's
2687
+ //-- pushState. //-- SG
2688
+ bInitialPageLoad = false;
2689
+ view.docApp.book = {};
2690
+ view.docApp.mcpCatalogMode = true;
2691
+ document.title = "Connect an AI assistant - Hornbill Documentation";
2692
+
2693
+ view.loadMcpCatalog();
2694
+
2695
+ revealApp();
2696
+
2697
+ currentPopState = null;
2698
+ }
2699
+ else if(bookId.indexOf("search?")===0 || bookId==="search")
2700
+ {
2701
+ //-- we are in search mode.
2702
+ bInitialPageLoad = false;
2703
+ view.docApp.book = {};
2704
+
2705
+ //-- do we have a search term to search for
2706
+
2707
+ view.docApp.searchFilterBySpecificBookId = "";
2708
+
2709
+ //-- parse the search criteria properly. The old split("?q=")/split("&bid=") parse
2710
+ //-- swallowed every OTHER param into the q value, so back-button onto a filtered
2711
+ //-- search (?q=workflow&dt=5) re-ran the search as q="workflow&dt=5" and the
2712
+ //-- rebuilt url showed q=workflow%26dt%3D5. //-- SG
2713
+ let searchUrlParams = new URL(window.location.href).searchParams;
2714
+ let initialSearch = searchUrlParams.get("q") || "";
2715
+ let initalBookIdFilter = searchUrlParams.get("bid") || "";
2716
+
2717
+ //-- restore the filters this history entry was made with. Only when present:
2718
+ //-- an absent dt is ambiguous (first load default vs auto-widened "everything"),
2719
+ //-- so leave the current filter state alone in that case //-- SG
2720
+ if(searchUrlParams.has("dt")) view.docApp.searchDocTypeFilter = searchUrlParams.get("dt");
2721
+ if(searchUrlParams.has("audience")) view.docApp.searchAudienceFilter = searchUrlParams.get("audience");
2722
+
2723
+ view.toolbarSearch(initialSearch,initalBookIdFilter,true);
2724
+
2725
+ //-- remove hidden class
2726
+ revealApp();
2727
+
2728
+ //-- this branch never reaches setBrowserViewUrl (the only place that consumes and
2729
+ //-- clears currentPopState), so clear it here - a stale value suppressed the
2730
+ //-- pushState of the NEXT normal link click, leaving the url stuck. //-- SG
2731
+ currentPopState = null;
2732
+ }
2733
+ else if(bookId)
2734
+ {
2735
+
2736
+ if(!findBookByIdAndLoad(bookId,loadingUrl))
2737
+ {
2738
+ //-- if we get here url was not found so goto base library
2739
+ view.loadLibraryHome();
2740
+ currentPopState = null; //-- see note in the search branch above //-- SG
2741
+ }
2742
+ }
2743
+ }
2744
+ else
2745
+ {
2746
+ //-- will reset view to home lib
2747
+ bInitialPageLoad = false;
2748
+ view.docApp.book = {};
2749
+ view.docApp.altSearchText = "";
2750
+
2751
+ //-- we have come from a back or forward browser button
2752
+ let startingLibraryProduct = urlGetParam(window.location.search,"product");
2753
+ let startingLibraryMode = urlGetParam(window.location.search,"mode");
2754
+
2755
+ let selectLayoutMode = getLibraryLayoutByModeParam(startingLibraryMode);
2756
+ organiseLibrary(selectLayoutMode,startingLibraryProduct);
2757
+
2758
+ //-- remove hidden class
2759
+ revealApp();
2760
+
2761
+ //-- see note in the search branch above: without this, back-button to the library
2762
+ //-- home left currentPopState set, and the next book click never updated the url
2763
+ //-- (then the click after that pushed onto the wrong base entry, so back skipped a
2764
+ //-- page). This was the reported "back button misses out the How-to page" defect. //-- SG
2765
+ currentPopState = null;
2766
+ }
2767
+
2768
+ }
2769
+
2770
+ function findBookByIdAndLoad(bookId,fromPopStateUrl)
2771
+ {
2772
+ //-- we have a book id fronm link like https://docs.hornbill.com/itom-config#itom-subscription-tiers instead of https://docs.hornbill.com/itom-config/index#itom-subscription-tiers
2773
+ if(bookId.indexOf("#")>0)
2774
+ {
2775
+ bookId = bookId.split("#",1)[0];
2776
+ }
2777
+
2778
+ let book = getLibraryBooksIndex().get(bookId);
2779
+ if(book)
2780
+ {
2781
+ view.loadLibraryBook(book,fromPopStateUrl);
2782
+ return true;
2783
+ }
2784
+
2785
+ return false;
2786
+ }
2787
+
2788
+
2789
+ function setLibraryGroupTitleAndDescription(aGroup)
2790
+ {
2791
+ //-- need to get the title and description somehow
2792
+ if(aGroup.mode==="audience")
2793
+ {
2794
+ aGroup.title = (aGroup.id==="public")?"Public Library":"Internal Library";
2795
+ //aGroup.description = (aGroup.id==="public")?"Explore all documents, guides and articles.":"Documents for internal user only, relating to internal processes, operations and technical information."
2796
+ aGroup.description = (aGroup.id==="public")?"Explore here. Use the toggle to the right to browse by audience or product.":"Documents for internal users only."
2797
+ }
2798
+ else if(aGroup.mode==="productFamilyId")
2799
+ {
2800
+ for(let x=0;x<view.docApp.products.length;x++)
2801
+ {
2802
+ if(aGroup.id == view.docApp.products[x].id)
2803
+ {
2804
+ aGroup.title = view.docApp.products[x].name;
2805
+
2806
+
2807
+ if(view.docApp.products[x].icon)
2808
+ {
2809
+ aGroup.icon = view.docApp.products[x].icon.replace("images/","images/products/");
2810
+ }
2811
+ else{
2812
+ aGroup.icon = "images/products/hornbill-square.svg";
2813
+ }
2814
+ return aGroup;
2815
+ }
2816
+ }
2817
+ }
2818
+
2819
+ return aGroup;
2820
+ }
2821
+
2822
+ function getLibraryLayoutByModeParam(groupByModeParam)
2823
+ {
2824
+ for(let x=0;x<view.docApp.groupModes.length;x++)
2825
+ {
2826
+ if(view.docApp.groupModes[x].mode===groupByModeParam)
2827
+ {
2828
+ return view.docApp.groupModes[x]
2829
+ }
2830
+ }
2831
+
2832
+ return view.docApp.groupModes[0];
665
2833
  }
666
2834
 
667
- async function fetchContentFile(strFilePath) {
668
- return await fetch(strFilePath)
669
- .then((response) => response.text())
670
- .then((fileContent) => {
671
- return fileContent;
672
- });
2835
+
2836
+ function organiseLibrary(oGroupByMode,homeProductToShow)
2837
+ {
2838
+ view.docApp.selectedLibraryGroup = oGroupByMode;
2839
+
2840
+ //-- build the whole map in a local first, then assign it to the model in one go. Adding keys
2841
+ //-- straight onto view.docApp.libraryGroups leaves every group after "unknown" unobserved by
2842
+ //-- Vue 2 (it cannot see properties added to an already-reactive object), so in-place edits to
2843
+ //-- a group - adding/removing a book, changing a count - would never re-render. //-- SG
2844
+ let builtGroups = {"unknown":{title:"Other",description:"",icon : "images/products/hornbill-square.svg", books:[]}};
2845
+
2846
+ //-- loop library content and put into groups
2847
+ let groupByMode = oGroupByMode.name;
2848
+ for(let x=0;x<view.docApp.library.books.length;x++)
2849
+ {
2850
+ if(view.docApp.library.books[x].hasOwnProperty(groupByMode))
2851
+ {
2852
+ //-- 11//07/2023 - fix audience so either public or internal (because at moment we have some books where audience is set to private and private.dev etc)
2853
+ if(groupByMode==="audience" && view.docApp.library.books[x].audience!=="public")
2854
+ {
2855
+ view.docApp.library.books[x].audience = "internal";
2856
+ }
2857
+
2858
+ let groupingName = view.docApp.library.books[x][groupByMode];
2859
+ if(!builtGroups.hasOwnProperty(groupingName))
2860
+ {
2861
+ let newGrouping = {id:groupingName,mode:groupByMode,title:groupingName,description:"",icon : "images/products/hornbill-square.svg",books:[]};
2862
+ builtGroups[groupingName] = setLibraryGroupTitleAndDescription(newGrouping);
2863
+ }
2864
+
2865
+ builtGroups[groupingName].books.push(view.docApp.library.books[x]);
2866
+
2867
+ }
2868
+ else
2869
+ {
2870
+ //-- needs to go into "uncagtegorised" or "other"
2871
+ builtGroups["unknown"].books.push(view.docApp.library.books[x]);
2872
+ }
2873
+ }
2874
+
2875
+ //-- single assignment - Vue observes builtGroups in place, so every group is now reactive
2876
+ view.docApp.libraryGroups = builtGroups;
2877
+
2878
+ if(homeProductToShow && view.docApp.libraryGroups[homeProductToShow])
2879
+ {
2880
+ view.docApp.libraryHomeViewProduct = view.docApp.libraryGroups[homeProductToShow];
2881
+ }
2882
+ else
2883
+ {
2884
+ view.docApp.libraryHomeViewProduct = null;
2885
+ }
2886
+
673
2887
  }
674
2888
 
675
- async function fetchJsonFile(strFilePath) {
676
- return await fetch(strFilePath)
677
- .then((response) => response.json())
678
- .then((jsonObject) => {
679
- return jsonObject;
680
- });
2889
+
2890
+ //-- fetchOptions is optional and passed straight to fetch() - used to hand in an
2891
+ //-- AbortController signal for the search request. //-- SG
2892
+ function fetchJsonFile(strFilePath,fetchOptions)
2893
+ {
2894
+ //-- the try/catch that used to sit around response.json() could never fire: json()
2895
+ //-- returns a promise and rejects asynchronously, long after the try block has exited,
2896
+ //-- so a malformed body became an unhandled rejection and the caller's await never
2897
+ //-- resolved usefully. Catch on the promise instead. //-- SG
2898
+ return fetch(strFilePath,fetchOptions).then(function(response)
2899
+ {
2900
+ if(response && response.ok)
2901
+ {
2902
+ return response.json().catch(function(e)
2903
+ {
2904
+ hslDocLog.log("fetchJsonFile: bad JSON body from ["+strFilePath+"]",e);
2905
+ return null;
2906
+ });
2907
+ }
2908
+
2909
+ return null;
2910
+ });
681
2911
  }
682
2912
 
2913
+
683
2914
  //-- THE INIT APP CALLED FROM index.html
684
- //$(".document-body").attr("v-pre", "");
685
-
686
- const view = new Vue({
687
- el: "#vDocDevApp",
688
- data: {
689
- userSession: {},
690
- updateCounter: 0,
691
- bookId: "",
692
- docApp: {
693
- error: "",
694
- inlinePopupDocumentHref: "",
695
- showInlineDocumentsMarkup: false,
696
- keepTocLayout: false,
697
- book: {},
698
- navSections: [],
699
- inlineDocumentItems: [],
700
- tableOfContents: [],
701
- bookBreadCrumb: [],
702
- },
703
- },
704
- methods: docAppMethods,
705
- directives: {
706
- somedirectivename: {
707
- bind(el, binding, vnode) {
708
- //-- do something when created on element
709
- },
710
- },
711
- },
712
- mounted: () => {},
713
- });
714
2915
 
715
- //-- repeatable conmponent for nav sections
716
- Vue.component("nav-section-component", {
717
- props: ["asection"],
718
- template: "#nav-section-template",
719
- methods: {
720
- toggleNavCollapse: function (navSection) {
721
- navSection.expand = !navSection.expand;
722
- this.$forceUpdate();
723
- },
724
- },
725
- });
2916
+ //-- here it is important to add v-pre to document-body content div so that vue does not process any vue type sytnax in the document content i.e. without this vue would try parse out {{dob}}
2917
+ //$(".injected-document-content").attr("v-pre","");
2918
+ document.querySelectorAll(".document-body").forEach(function(el){ el.setAttribute("v-pre",""); });
2919
+
2920
+ //-- repeatable conmponent for nav sections.
2921
+ //-- registered BEFORE the app is created: Vue 3 resolves components from the app instance, and
2922
+ //-- hbCreateApp copies the registry at creation time, so anything registered afterwards would
2923
+ //-- not be visible to this app. (Vue 2 had a global registry and did not care about order.)
2924
+ hbVue.component('nav-section-component', {
2925
+ props: ['asection'],
2926
+ template: '#nav-section-template',
2927
+ methods:{
2928
+ toggleNavCollapse:function(navSection)
2929
+ {
2930
+ //-- no $forceUpdate() needed: navSection reaches us through the reactive proxy, and
2931
+ //-- renderNavigation() seeds expand, so this assignment triggers a targeted re-render
2932
+ //-- rather than needing a forced repaint of the whole component. //-- SG
2933
+ navSection.expand=!navSection.expand;
2934
+ }
2935
+ }
2936
+ });
2937
+
2938
+ //-- `data` is a function in Vue 3 - an object literal is no longer accepted, because every app
2939
+ //-- created from the same options object would otherwise share one state object. //-- SG
2940
+ //-- Reveal the app once a view is ready.
2941
+ //--
2942
+ //-- hb-hidden is a PRE-VUE guard: visibility:hidden !important from a static <style> so the
2943
+ //-- uncompiled template - raw mustaches, v-if markup - is never painted while Vue boots. That
2944
+ //-- is exactly why it cannot be a Vue binding: before Vue runs, a :class attribute does nothing
2945
+ //-- and the flash comes back.
2946
+ //--
2947
+ //-- So it belongs on the mount host and nowhere else. The host is the one element Vue does not
2948
+ //-- own - it renders INTO it - which is what makes removing the class here safe. The same class
2949
+ //-- on elements INSIDE the template was redundant (visibility inherits) and was a latent copy of
2950
+ //-- the bug that killed the search Filters button: Vue rewrites the static class list on every
2951
+ //-- re-render and puts the flag straight back. //-- SG
2952
+ function revealApp()
2953
+ {
2954
+ //-- index.html loads its stylesheets non-render-blocking (media="print" flipped on load) so
2955
+ //-- the boot splash can paint without waiting for all 15 of them. The trade is that the app
2956
+ //-- must not be revealed before they have applied, or it appears unstyled - so the wait moved
2957
+ //-- from the browser's first paint to here. Re-entrant: queue once, and hbCssMarkReady() calls
2958
+ //-- us back.
2959
+ //-- Guarded on hbCssWaiters existing because _inline.html shares this file and does NOT set up
2960
+ //-- any of this - there, and anywhere else without the globals, reveal is immediate as before.
2961
+ if(window.hbCssWaiters && !window.hbCssReady)
2962
+ {
2963
+ if(window.hbCssWaiters.indexOf(revealApp) === -1) window.hbCssWaiters.push(revealApp);
2964
+ return;
2965
+ }
2966
+
2967
+ let host = document.getElementById("vDocDevApp");
2968
+ if(host) host.classList.remove("hb-hidden");
2969
+
2970
+ //-- the old global $(".hb-hidden") sweep also cleared the content-loading hide, so this
2971
+ //-- keeps that coupling rather than making every caller remember two things. Guarded
2972
+ //-- because init calls this before `view` is assigned. //-- SG
2973
+ if(typeof view !== "undefined" && view && view.docApp) view.docApp.contentLoading = false;
2974
+
2975
+ hbHideBootSplash();
2976
+ }
726
2977
 
2978
+ //-- Tear down the #hbBootSplash boot state (markup and CSS both in index.html). Called from
2979
+ //-- revealApp(), which every success AND every failure path already funnels through - that matters
2980
+ //-- more than it looks: a splash that outlives a failed boot is a permanently spinning page, which
2981
+ //-- is worse than the blank one it replaced. The 20s CSS-only stall message is the last resort for
2982
+ //-- a boot that never reaches here at all.
2983
+ //--
2984
+ //-- Two-stage removal so the fade actually runs: class first (CSS transitions opacity to 0), then
2985
+ //-- display:none once it is done. The display:none is not cosmetic - the element is a fixed,
2986
+ //-- full-viewport layer, so leaving it at opacity 0 would swallow every click on the site. Belt
2987
+ //-- and braces on the teardown timer because transitionend does not fire if the element was still
2988
+ //-- inside its 250ms opacity hold (no transition to end) or if motion is reduced. //-- SG
2989
+ function hbHideBootSplash()
2990
+ {
2991
+ let splash = document.getElementById("hbBootSplash");
2992
+ if(!splash || splash.classList.contains("hbBootSplashGone")) return;
2993
+
2994
+ splash.classList.add("hbBootSplashGone");
2995
+ splash.setAttribute("aria-hidden","true");
2996
+
2997
+ let removed = false;
2998
+ let finish = function()
2999
+ {
3000
+ if(removed) return;
3001
+ removed = true;
3002
+ splash.style.display = "none";
3003
+ };
3004
+
3005
+ splash.addEventListener("transitionend", finish, { once:true });
3006
+ setTimeout(finish, 400);
3007
+ }
727
3008
 
728
- const hslDocLog = {
729
- _entry: [],
730
- log: function (strMessage, varData) {
731
- if (varData !== undefined) console.log(strMessage, varData);
732
- else console.log(strMessage);
3009
+ var view = hbMount({
3010
+ data: function(){ return {
3011
+ docApp:{
3012
+ //-- These 14 were previously never declared here, only assigned at runtime with a
3013
+ //-- plain view.docApp.x = ..., yet every one of them is bound in index.html or
3014
+ //-- _inline.html. Under Vue 2 that meant none of them were reactive and the app
3015
+ //-- only repainted because of the scattered $forceUpdate() calls; Vue 3's Proxy
3016
+ //-- tracks late-added properties, so declaring them is no longer a bug fix - it is
3017
+ //-- for discoverability, and it is what lets the $forceUpdate() calls go.
3018
+ //--
3019
+ //-- Initial values are deliberately falsy-equivalent to the undefined they replace,
3020
+ //-- so first render is byte-identical. loadingBookInfo especially MUST start false:
3021
+ //-- the toolbar renders on v-if="!docApp.loadingBookInfo", so seeding it true would
3022
+ //-- hide the toolbar until a book load finished. //-- SG
3023
+ ThemePreference:"",
3024
+ //-- content hidden while the next document loads - see revealApp() //-- SG
3025
+ contentLoading:false,
3026
+ //-- book navigation sidebar, open state on narrow viewports //-- SG
3027
+ bookNavOpen:false,
3028
+ //-- frontmatter layout class applied to #DocContent //-- SG
3029
+ layoutClass:"",
3030
+ userSession:null,
3031
+ products:[],
3032
+ title:"",
3033
+ description:"",
3034
+ node:"",
3035
+ build:"",
3036
+ inlineTitle:"",
3037
+ loadingBookInfo:false,
3038
+ altSearchText:"",
3039
+ currentResultsSearchText:"",
3040
+ validSearch:false, //-- TRUE = odd number of quotes, i.e. the search is INVALID (feeds the warning in index.html)
3041
+ searchFilterBySpecificBookTitle:"",
3042
+
3043
+ boolSearchInProgress:false,
3044
+ nextSearchCriteria:null,
3045
+ searchText:"",
3046
+ searchError:"",
3047
+ //-- TRUE while the box holds a single character: no search can run (see the
3048
+ //-- length gate in searchDocuments), so the results area says so rather than
3049
+ //-- showing nothing or the previous query's hits //-- SG
3050
+ searchTooShort:false,
3051
+ //-- transient hint under the toolbar search box, for the one case where the
3052
+ //-- length gate changes nothing on screen (Enter on a single character while
3053
+ //-- reading a document) - see showSearchTooShortHint() //-- SG
3054
+ searchTooShortHint:false,
3055
+ searchMode:false,
3056
+ //-- MCP catalog view (/mcp). Mutually exclusive with the search, library
3057
+ //-- and book views - cleared wherever searchMode is. //-- SG
3058
+ mcpCatalogMode:false,
3059
+ mcpCatalog:{servers:[],loaded:false,error:false},
3060
+ //-- key -> true/false while the "copied" tick shows on a connection field
3061
+ mcpCopied:{},
3062
+ searchResponse:null,
3063
+ hasFilterByProducts:false,
3064
+ searchFilteredProducts:{},
3065
+ searchFilterBySpecificBookId:"",
3066
+ searchResultFilteredItems:[],
3067
+ //-- diagnostic keyword/semantic/both badges on search results are internal-only:
3068
+ //-- hidden on the public site, visible on docs-internal and dev/localhost //-- SG
3069
+ showSearchMatchBadges: window.location.hostname !== "docs.hornbill.com",
3070
+ //-- audience filter is internal-only too: the public site hosts public books
3071
+ //-- only, so a picker there is meaningless. Session-only state, deliberately
3072
+ //-- not persisted to the /search url. Values mirror the hdocbook.json
3073
+ //-- audience enum (hdocbook.schema.json in hdoc-tools) //-- SG
3074
+ showAudienceFilter: window.location.hostname !== "docs.hornbill.com",
3075
+ searchAudienceFilter:"",
3076
+ //-- Content-type facet. Defaults to "0" (Documentation) because reference
3077
+ //-- material is ~85% of the corpus, so an unfiltered search buries plain
3078
+ //-- answers: "reset a password" returned four API pages before anything a
3079
+ //-- non-technical user could act on. "" means All.
3080
+ //-- On docs-internal only, the user's last explicit pick is restored from
3081
+ //-- localStorage instead - see docTypeUserDefaultFilter() //-- SG
3082
+ searchDocTypeFilter:docTypeUserDefaultFilter(),
3083
+ //-- set when the default filter is hiding better matches than it is showing;
3084
+ //-- drives the offer above the results so filtering never dead-ends
3085
+ searchDocTypeRescue:null,
3086
+ //-- set when the url's bid named a book that does not exist and the filter
3087
+ //-- was cleared automatically - drives the explanation above the results
3088
+ searchBookRescue:null,
3089
+ //-- narrow-viewport filter panel visibility
3090
+ searchFiltersOpen:false,
3091
+ searchAudienceOptions:[
3092
+ {value:"",label:"All"},
3093
+ {value:"public",label:"Public"},
3094
+ {value:"private",label:"Private"},
3095
+ {value:"private.cloud",label:"Private: Cloud"},
3096
+ {value:"private.dev.platform",label:"Private: Platform Dev"},
3097
+ {value:"private.dev.apps",label:"Private: Apps Dev"},
3098
+ {value:"private.hdocs",label:"Private: HDocs"},
3099
+ {value:"private.elearning",label:"Private: eLearning"}
3100
+ ],
3101
+ frontmatterData:{}, //-- always an object, was initialised as [] //-- SG
3102
+ keepTocLayout:false,
3103
+ tocMobileOpen:false,
3104
+ library:{},
3105
+ libraryGroups:{},
3106
+ libraryHomeViewProduct:null,
3107
+ book:{},
3108
+ bookInfo:null,
3109
+ navSections:[],
3110
+ tableOfContents:[],
3111
+ selectedLibraryGroup : null,
3112
+ groupModes:[
3113
+ {name:"audience",mode:"audience",title:"By Audience",layout:"column-list"},
3114
+ {name:"productFamilyId",mode:"product",title:"By Product", layout:"tiles"}
3115
+ ]
3116
+ }
3117
+ };
3118
+ },
3119
+ methods:docAppMethods
3120
+ }, '#vDocDevApp');
3121
+
3122
+ //-- Vue 3 compiles the mount element's innerHTML and leaves the element itself alone, so the
3123
+ //-- v-on:click that used to sit on #vDocDevApp is no longer compiled. index.html declares it as
3124
+ //-- data-root-click and we attach it here; _inline.html omits the attribute and so gets no
3125
+ //-- handler, matching what it did before. //-- SG
3126
+ hbBindRootHandler(view, '#vDocDevApp', 'click');
3127
+
3128
+ //-- One delegated click handler for every link on the page, for the life of the session.
3129
+ //-- Bound here alongside the root handler so it is in place before any content is injected.
3130
+ bindDelegatedLinkHandler();
3131
+
3132
+ var hslDocLog={
3133
+ _entry:[],
3134
+ //-- ring buffer. This used to push forever, and search entries retain whole response objects
3135
+ //-- (300 results each), so a long-lived tab leaked steadily for output nobody reads unless
3136
+ //-- they call dump(). //-- SG
3137
+ _maxEntries:500,
3138
+ log:function(strMessage,varData)
3139
+ {
3140
+ //if(varData!==undefined) console.log(strMessage,varData)
3141
+ //else console.log(strMessage);
3142
+
3143
+ this._entry.push({msg:strMessage,data:varData})
3144
+ if(this._entry.length > this._maxEntries) this._entry.splice(0,this._entry.length - this._maxEntries);
3145
+ },
3146
+ dump:function()
3147
+ {
3148
+ for(let x=0;x<this._entry.length;x++)
3149
+ {
3150
+ console.log(this._entry[x].msg,this._entry[x].data || null);
3151
+ }
3152
+ }
3153
+ }
733
3154
 
734
- this._entry.push({ msg: strMessage, data: varData });
735
- },
736
- dump: function () {
737
- for (let x = 0; x < this._entry.length; x++) {
738
- console.log(this._entry[x].msg, this._entry[x].data || null);
739
- }
740
- },
741
- };
3155
+ //-- Both return a DOM element or null. They returned jQuery collections, which no-op when
3156
+ //-- empty; callers must now null-check, and every one of them does. //-- SG
3157
+ function vueContentContainer(){
3158
+ return document.querySelector(".injected-document-content .vue-document-body");
3159
+ }
3160
+ function contentContainer(){
3161
+ return document.querySelector(".injected-document-content .document-body");
3162
+ }
742
3163
 
743
3164
  async function intialiseApp() {
744
- // set theme switch
745
- $(".theme-switch-checkbox").prop("checked", ThemePreference === "dark");
746
-
747
- jqDocumentHeader = $(".injected-document-content .document-header");
748
- jqContentContainer = $(".injected-document-content .document-body");
749
- //jqContentContainer = $(".injected-document-content");
750
- jqLeftNav = $("#DocSidebarNav");
751
-
752
- await fetchJsonFile("_books/library.json")
753
- .then((data) => {
754
- // Get docbook library list
755
- view.docApp.library = data;
756
- view.docId = view.docApp.library.books[0].docId;
757
- })
758
- .then(() => {
759
- // Get hdocbook.json
760
- fetchJsonFile(`_books/${view.docId}/hdocbook.json`)
761
- .then((data) => {
762
- view.docApp.book = data;
763
- view.bookId = data.docId;
764
- view.docApp.title = data.title || "";
765
- view.docApp.description = data.description || "";
766
-
767
- view.docApp.inlineDocumentItems = [];
768
- view.docApp.inlinePopupDocumentHref = "";
769
- view.docApp.showInlineDocumentsMarkup = false;
770
- view.docApp.hasInlinePopupDocuments =
771
- !!view.docApp.library.books[0].nav_inline?.items?.length;
772
- if (view.docApp.hasInlinePopupDocuments) {
773
- view.docApp.inlineDocumentItems =
774
- view.docApp.library.books[0].nav_inline.items;
775
- view.docApp.book.navigation.items.push(
776
- view.docApp.library.books[0].nav_inline,
777
- );
778
- }
779
-
780
- //-- now render out the nav menu
781
- view.renderNavigation();
782
-
783
- setTimeout(() => {
784
- listenForHrefClicks();
785
-
786
- //-- check if url is not root - in which case we need to load content for url
787
- let initialLoadContentHref = window.location.href.replace(
788
- `${window.location.origin}/`,
789
- "",
790
- );
791
- const newInitialLoadContentHref = findFirstClickableBookLink(
792
- initialLoadContentHref,
793
- );
794
- if (newInitialLoadContentHref !== undefined) initialLoadContentHref = newInitialLoadContentHref;
795
- if (initialLoadContentHref) {
796
- //-- find the link we are going to show and make sure parent/s are set to .expand=true
797
- loadContentUrl(initialLoadContentHref, true, false);
798
- } else {
799
- hslDocLog.log("initial first view link to load is not present");
800
- }
801
- $(".hb-hidden").removeClass("hb-hidden");
802
- }, 200);
803
- })
804
- .catch((data) => {
805
- view.docApp.error = data;
806
- $(".hb-hidden").removeClass("hb-hidden");
807
- });
808
- });
809
- }
3165
+
3166
+ document.title = "Hornbill Documentation";
3167
+
3168
+ view.docApp.ThemePreference = ThemePreference;
3169
+
3170
+ //-- get session info
3171
+ hslDocLog.log("Hornbill docs get session");
3172
+ await fetchJsonFile("_api/session/").then(function(sessionData)
3173
+ {
3174
+ hslDocLog.log("Hornbill docs get session response",sessionData);
3175
+ if(sessionData && sessionData.type==="user")
3176
+ {
3177
+ hslDocLog.log("Hornbill docs set session set expires date time to browser locale")
3178
+ sessionData.expiresDateTime = new Date(sessionData.validUntil).toLocaleDateString() + ' ' + new Date(sessionData.validUntil).toLocaleTimeString();
3179
+ hslDocLog.log("Hornbill docs update vue session object model",sessionData);
3180
+ //-- userSession is declared in data(), so assigning it re-renders the profile dropdown
3181
+ //-- and the admin gear on its own - no $forceUpdate() needed. //-- SG
3182
+ view.setSessionInfo(sessionData);
3183
+ }
3184
+ });
3185
+ //-- eof session stuff
3186
+
3187
+
3188
+ await fetchJsonFile("_books/products.json").then(function(data){
3189
+
3190
+ //-- alphabetical by display name - drives the Products filter list order; everything
3191
+ //-- else looks products up by id, so sort order is free to be presentational
3192
+ view.docApp.products = (data.products || []).sort(function(a,b)
3193
+ {
3194
+ return (a.name || "").localeCompare(b.name || "", undefined, {sensitivity:"base"});
3195
+ });
3196
+
3197
+ return fetchJsonFile("_books/library.json").then(function(data){
3198
+
3199
+ // Get docbook library list
3200
+ view.docApp.library = data;
3201
+
3202
+ //-- NOTE: book order is whatever the server returns; there is no client-side sort.
3203
+
3204
+ let startingLibraryProduct = urlGetParam(window.location.search,"product");
3205
+ let startingLibraryMode = urlGetParam(window.location.search,"mode");
3206
+ let selectLayoutMode = getLibraryLayoutByModeParam(startingLibraryMode);
3207
+ organiseLibrary(selectLayoutMode,startingLibraryProduct);
3208
+
3209
+ }).then(function(){
3210
+
3211
+ loadLibraryOrBookFromCurrentUrl()
3212
+ });
3213
+ });
3214
+ }