]> BookStack Code Mirror - hacks/blob - content/dynamic-glossary/head.html
Reviewed mermaid viewer hack
[hacks] / content / dynamic-glossary / head.html
1 <script type="module">
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';
6
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');
11
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());
18     }
19
20     /**
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.
25      */
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;
34             let parsedWords = [];
35             let firstChange = true;
36             for (const word of words) {
37                 const normalisedWord = word.toLowerCase().replace(trimRegex, '');
38                 const glossaryVal = glossary[normalisedWord];
39                 if (glossaryVal) {
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, '');
47                     parsedWords = [];
48                     firstChange = false;
49                     continue;
50                 }
51
52                 parsedWords.push(word);
53             }
54         }
55     }
56
57     /**
58      * Create the element for a glossary term.
59      * @param {string} term
60      * @param {string} description
61      * @returns {Element}
62      */
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;
68         return termEl;
69     }
70
71     /**
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>>}
76      */
77     async function getMergedGlossariesForPage(pagePath) {
78         const [defaultGlossary, bookGlossary] = await Promise.all([
79             getGlossaryFromPath(defaultGlossaryPage),
80             getBookGlossary(pagePath),
81         ]);
82
83         return Object.assign({}, defaultGlossary, bookGlossary);
84     }
85
86     /**
87      * Get the glossary for the book of page found at the given path.
88      * @param {string} pagePath
89      * @returns {Promise<Object<string, string>>}
90      */
91     async function getBookGlossary(pagePath) {
92         const bookPath = pagePath.split('/page/')[0];
93         const glossaryPath = bookPath + '/page/glossary';
94         return await getGlossaryFromPath(glossaryPath);
95     }
96
97     /**
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.
101      * @param path
102      * @returns {Promise<{}|any>}
103      */
104     async function getGlossaryFromPath(path) {
105         const key = 'bsglossary:' + path;
106         const storageVal = window.localStorage.getItem(key);
107         if (storageVal) {
108             return JSON.parse(storageVal);
109         }
110
111         let resp = null;
112         try {
113             resp = await window.$http.get(path);
114         } catch (err) {
115         }
116
117         let map = {};
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');
121             if (contentEl) {
122                 map = domToTermMap(contentEl);
123             }
124         }
125
126         addTermMapToStorage(path, map);
127         return map;
128     }
129
130     /**
131      * Store a term map in storage for the given path.
132      * @param {string} urlPath
133      * @param {Object<string, string>} map
134      */
135     function addTermMapToStorage(urlPath, map) {
136         window.localStorage.setItem('bsglossary:' + urlPath, JSON.stringify(map));
137     }
138
139     /**
140      * Convert the text of the given DOM into a map of definitions by term.
141      * @param {string} text
142      * @return {Object<string, string>}
143      */
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');
147         const map = {};
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(':');
153             }
154         }
155         return map;
156     }
157 </script>
158 <style>
159     /**
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.
164      */
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;
170         position: relative;
171         cursor: help;
172     }
173     .page-content .glossary-term:hover:after {
174         display: block;
175     }
176
177     .page-content .glossary-term:after {
178         position: absolute;
179         content: attr(data-term);
180         background-color: #FFF;
181         width: 200px;
182         box-shadow: 0 1px 6px 0 rgba(0, 0, 0, 0.15);
183         padding: 0.5rem 1rem;
184         font-size: 12px;
185         border-radius: 3px;
186         z-index: 20;
187         top: 2em;
188         inset-inline-start: 0;
189         display: none;
190     }
191     .dark-mode .page-content .glossary-term:after {
192         background-color: #000;
193     }
194 </style>