Hacks & Customizations
These hacks are unsupported customizations meant as unofficial workarounds.
They can cause instability, introduce issues and may conflict with future updates. Apply at your own risk!

Dynamic Glossary

  • Author: @ssddanbrown
  • Created: 13th Nov 2024
  • Updated: 13th Nov 2024
  • Last Tested On: v24.10.2

This hack adds the ability to write global and book-level glossaries, which will then be utilised when viewing pages by marking those words in page content with a dashed underline. On hover, the set definitions will show in a popup below the word.

Considerations

  • This will only work for single-word terms (terms without a space). Multi-word terms would require more complex and computationally costly logic.
  • The term matching is quite simplistic and case-insensitive, with words/terms trimmed, lower-cased, and with common punctuation trimmed before matching.
  • This runs in the browser after a page has loaded, so the term highlighting may seem to pop-in just after page load.
  • This will perform requests in the background to load in the definitions from other pages.
  • The glossary terms are stored after the first fetch, so changes to the glossary may not be represented. You can force a refresh of this by opening a relevant “glossary page”.
  • Accessibility has not been considered in this implementation.
  • This will only work for standard page viewing, not for exports and other page viewing options.

Usage

“Glossary pages” are just normal BookStack pages, but with their content in the format:

1
Term: Description for term

For example:

1
2
3
BookStack: An open source documentation platform built on a PHP & MySQL stack.

Cat: A type of small house tiger that constantly demands food.

Create a page somewhere in your instance to act as the global glossary. With the hack code added to the “Custom HTML Head Content”, find the defaultGlossaryPage and tweak the path to match that of your created global glossary page. You should only need to change the my-book and main-glossary parts of the text, don’t change the format or other parts of that. Then save your changes.

That’s the global glossary configured. Any matching terms in page content will then be converted to a highlighted term on page view. The hack will also look-up a book-level glossary page at the <book-url>/pages/glossary URL. The terms in this book-level glossary page will override those in the global glossary. You can create this in the same manner as the global glossary, but it just needs to be part of the intended book, and named “Glossary”, so that it’s available on the expected path within the book.

Glossary terms are stored after being loaded, to avoid extra requests and loading time. You can view a glossary page to force the terms to be re-loaded and re-stored from that glossary page.

Code

head.html
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
<script type="module">
    // The URL path to the default glossary page in your instance.
    // You should only need to change the "my-book" and "main-glossary"
    // parts, keep the other parts and the general format the same.
    const defaultGlossaryPage = '/books/my-book/page/main-glossary';

    // Get a normalised URL path, check if it's a glossary page, and page the page content
    const urlPath = window.location.pathname.replace(/^.*?\/books\//, '/books/');
    const isGlossaryPage = urlPath.endsWith('/page/glossary') || urlPath.endsWith(defaultGlossaryPage);
    const pageContentEl = document.querySelector('.page-content');

    if (isGlossaryPage && pageContentEl) {
        // Force re-index when viewing glossary pages
        addTermMapToStorage(urlPath, domToTermMap(pageContentEl));
    } else if (pageContentEl) {
        // Get glossaries and highlight when viewing non-glossary pages
        document.addEventListener('DOMContentLoaded', () => highlightTermsOnPage());
    }

    /**
     * Highlight glossary terms on the current page that's being viewed.
     * In this, we get our combined glossary, then walk each text node to then check
     * each word of the text against the glossary. Where exists, we split the text
     * and insert a new glossary term span element in its place.
     */
    async function highlightTermsOnPage() {
        const glossary = await getMergedGlossariesForPage(urlPath);
        const trimRegex = /^[.?:"',;]|[.?:"',;]$/g;
        const treeWalker = document.createTreeWalker(pageContentEl, NodeFilter.SHOW_TEXT);
        while (treeWalker.nextNode()) {
            const node = treeWalker.currentNode;
            const words = node.textContent.split(' ');
            const parent = node.parentNode;
            let parsedWords = [];
            let firstChange = true;
            for (const word of words) {
                const normalisedWord = word.toLowerCase().replace(trimRegex, '');
                const glossaryVal = glossary[normalisedWord];
                if (glossaryVal) {
                    const preText = parsedWords.join(' ');
                    const preTextNode = new Text((firstChange ? '' : ' ') + preText + ' ');
                    parent.insertBefore(preTextNode, node)
                    const termEl = createGlossaryNode(word, glossaryVal);
                    parent.insertBefore(termEl, node);
                    const toReplace = parsedWords.length ? preText + ' ' + word : word;
                    node.textContent = node.textContent.replace(toReplace, '');
                    parsedWords = [];
                    firstChange = false;
                    continue;
                }

                parsedWords.push(word);
            }
        }
    }

    /**
     * Create the element for a glossary term.
     * @param {string} term
     * @param {string} description
     * @returns {Element}
     */
    function createGlossaryNode(term, description) {
        const termEl = document.createElement('span');
        termEl.setAttribute('data-term', description.trim());
        termEl.setAttribute('class', 'glossary-term');
        termEl.textContent = term;
        return termEl;
    }

    /**
     * Get a merged glossary object for a given page.
     * Combines the terms for a same-book & global glossary.
     * @param {string} pagePath
     * @returns {Promise<Object<string, string>>}
     */
    async function getMergedGlossariesForPage(pagePath) {
        const [defaultGlossary, bookGlossary] = await Promise.all([
            getGlossaryFromPath(defaultGlossaryPage),
            getBookGlossary(pagePath),
        ]);

        return Object.assign({}, defaultGlossary, bookGlossary);
    }

    /**
     * Get the glossary for the book of page found at the given path.
     * @param {string} pagePath
     * @returns {Promise<Object<string, string>>}
     */
    async function getBookGlossary(pagePath) {
        const bookPath = pagePath.split('/page/')[0];
        const glossaryPath = bookPath + '/page/glossary';
        return await getGlossaryFromPath(glossaryPath);
    }

    /**
     * Get/build a glossary from the given page path.
     * Will fetch it from the localstorage cache first if existing.
     * Otherwise, will attempt the load it by fetching the page.
     * @param path
     * @returns {Promise<{}|any>}
     */
    async function getGlossaryFromPath(path) {
        const key = 'bsglossary:' + path;
        const storageVal = window.localStorage.getItem(key);
        if (storageVal) {
            return JSON.parse(storageVal);
        }

        let resp = null;
        try {
            resp = await window.$http.get(path);
        } catch (err) {
        }

        let map = {};
        if (resp && resp.status === 200 && typeof resp.data === 'string') {
            const doc = (new DOMParser).parseFromString(resp.data, 'text/html');
            const contentEl = doc.querySelector('.page-content');
            if (contentEl) {
                map = domToTermMap(contentEl);
            }
        }

        addTermMapToStorage(path, map);
        return map;
    }

    /**
     * Store a term map in storage for the given path.
     * @param {string} urlPath
     * @param {Object<string, string>} map
     */
    function addTermMapToStorage(urlPath, map) {
        window.localStorage.setItem('bsglossary:' + urlPath, JSON.stringify(map));
    }

    /**
     * Convert the text of the given DOM into a map of definitions by term.
     * @param {string} text
     * @return {Object<string, string>}
     */
    function domToTermMap(dom) {
        const textEls = Array.from(dom.querySelectorAll('p,h1,h2,h3,h4,h5,h6,blockquote'));
        const text = textEls.map(el => el.textContent).join('\n');
        const map = {};
        const lines = text.split('\n');
        for (const line of lines) {
            const split = line.trim().split(':');
            if (split.length > 1) {
                map[split[0].trim().toLowerCase()] = split.slice(1).join(':');
            }
        }
        return map;
    }
</script>
<style>
    /**
     * These are the styles for the glossary terms and definition popups.
     * To keep things simple, the popups are not elements themselves, but
     * pseudo ":after" elements on the terms, which gain their text via
     * the "data-term" attribute on the term element.
     */
    .page-content .glossary-term {
        text-decoration: underline;
        text-decoration-style: dashed;
        text-decoration-color: var(--color-link);
        text-decoration-thickness: 1px;
        position: relative;
        cursor: help;
    }
    .page-content .glossary-term:hover:after {
        display: block;
    }

    .page-content .glossary-term:after {
        position: absolute;
        content: attr(data-term);
        background-color: #FFF;
        width: 200px;
        box-shadow: 0 1px 6px 0 rgba(0, 0, 0, 0.15);
        padding: 0.5rem 1rem;
        font-size: 12px;
        border-radius: 3px;
        z-index: 20;
        top: 2em;
        inset-inline-start: 0;
        display: none;
    }
    .dark-mode .page-content .glossary-term:after {
        background-color: #000;
    }
</style>

Request an Update

Hack not working on the latest version of BookStack?
You can request this hack to be updated & tested for a small one-time fee.
This helps keeps these hacks updated & maintained in a sustainable manner.


Latest Hacks