2 // The URL path to the default glossary page in your instance.
3 // You should only need to change the "my-book" and "main-glossary"
4 // parts, keep the other parts and the general format the same.
5 const defaultGlossaryPage = '/books/my-book/page/main-glossary';
7 // Get a normalised URL path, check if it's a glossary page, and page the page content
8 const urlPath = window.location.pathname.replace(/^.*?\/books\//, '/books/');
9 const isGlossaryPage = urlPath.endsWith('/page/glossary') || urlPath.endsWith(defaultGlossaryPage);
10 const pageContentEl = document.querySelector('.page-content');
12 if (isGlossaryPage && pageContentEl) {
13 // Force re-index when viewing glossary pages
14 addTermMapToStorage(urlPath, domToTermMap(pageContentEl));
15 } else if (pageContentEl) {
16 // Get glossaries and highlight when viewing non-glossary pages
17 document.addEventListener('DOMContentLoaded', () => highlightTermsOnPage());
21 * Highlight glossary terms on the current page that's being viewed.
22 * In this, we get our combined glossary, then walk each text node to then check
23 * each word of the text against the glossary. Where exists, we split the text
24 * and insert a new glossary term span element in its place.
26 async function highlightTermsOnPage() {
27 const glossary = await getMergedGlossariesForPage(urlPath);
28 const trimRegex = /^[.?:"',;]|[.?:"',;]$/g;
29 const treeWalker = document.createTreeWalker(pageContentEl, NodeFilter.SHOW_TEXT);
30 while (treeWalker.nextNode()) {
31 const node = treeWalker.currentNode;
32 const words = node.textContent.split(' ');
33 const parent = node.parentNode;
35 let firstChange = true;
36 for (const word of words) {
37 const normalisedWord = word.toLowerCase().replace(trimRegex, '');
38 const glossaryVal = glossary[normalisedWord];
40 const preText = parsedWords.join(' ');
41 const preTextNode = new Text((firstChange ? '' : ' ') + preText + ' ');
42 parent.insertBefore(preTextNode, node)
43 const termEl = createGlossaryNode(word, glossaryVal);
44 parent.insertBefore(termEl, node);
45 const toReplace = parsedWords.length ? preText + ' ' + word : word;
46 node.textContent = node.textContent.replace(toReplace, '');
52 parsedWords.push(word);
58 * Create the element for a glossary term.
59 * @param {string} term
60 * @param {string} description
63 function createGlossaryNode(term, description) {
64 const termEl = document.createElement('span');
65 termEl.setAttribute('data-term', description.trim());
66 termEl.setAttribute('class', 'glossary-term');
67 termEl.textContent = term;
72 * Get a merged glossary object for a given page.
73 * Combines the terms for a same-book & global glossary.
74 * @param {string} pagePath
75 * @returns {Promise<Object<string, string>>}
77 async function getMergedGlossariesForPage(pagePath) {
78 const [defaultGlossary, bookGlossary] = await Promise.all([
79 getGlossaryFromPath(defaultGlossaryPage),
80 getBookGlossary(pagePath),
83 return Object.assign({}, defaultGlossary, bookGlossary);
87 * Get the glossary for the book of page found at the given path.
88 * @param {string} pagePath
89 * @returns {Promise<Object<string, string>>}
91 async function getBookGlossary(pagePath) {
92 const bookPath = pagePath.split('/page/')[0];
93 const glossaryPath = bookPath + '/page/glossary';
94 return await getGlossaryFromPath(glossaryPath);
98 * Get/build a glossary from the given page path.
99 * Will fetch it from the localstorage cache first if existing.
100 * Otherwise, will attempt the load it by fetching the page.
102 * @returns {Promise<{}|any>}
104 async function getGlossaryFromPath(path) {
105 const key = 'bsglossary:' + path;
106 const storageVal = window.localStorage.getItem(key);
108 return JSON.parse(storageVal);
113 resp = await window.$http.get(path);
118 if (resp && resp.status === 200 && typeof resp.data === 'string') {
119 const doc = (new DOMParser).parseFromString(resp.data, 'text/html');
120 const contentEl = doc.querySelector('.page-content');
122 map = domToTermMap(contentEl);
126 addTermMapToStorage(path, map);
131 * Store a term map in storage for the given path.
132 * @param {string} urlPath
133 * @param {Object<string, string>} map
135 function addTermMapToStorage(urlPath, map) {
136 window.localStorage.setItem('bsglossary:' + urlPath, JSON.stringify(map));
140 * Convert the text of the given DOM into a map of definitions by term.
141 * @param {string} text
142 * @return {Object<string, string>}
144 function domToTermMap(dom) {
145 const textEls = Array.from(dom.querySelectorAll('p,h1,h2,h3,h4,h5,h6,blockquote'));
146 const text = textEls.map(el => el.textContent).join('\n');
148 const lines = text.split('\n');
149 for (const line of lines) {
150 const split = line.trim().split(':');
151 if (split.length > 1) {
152 map[split[0].trim().toLowerCase()] = split.slice(1).join(':');
160 * These are the styles for the glossary terms and definition popups.
161 * To keep things simple, the popups are not elements themselves, but
162 * pseudo ":after" elements on the terms, which gain their text via
163 * the "data-term" attribute on the term element.
165 .page-content .glossary-term {
166 text-decoration: underline;
167 text-decoration-style: dashed;
168 text-decoration-color: var(--color-link);
169 text-decoration-thickness: 1px;
173 .page-content .glossary-term:hover:after {
177 .page-content .glossary-term:after {
179 content: attr(data-term);
180 background-color: #FFF;
182 box-shadow: 0 1px 6px 0 rgba(0, 0, 0, 0.15);
183 padding: 0.5rem 1rem;
188 inset-inline-start: 0;
191 .dark-mode .page-content .glossary-term:after {
192 background-color: #000;