Ability to create Glossary (in-line definitions) for words... #5043

Closed
opened 2026-02-05 09:36:14 +03:00 by OVERLORD · 8 comments
Owner

Originally created by @kristiandg on GitHub (Nov 11, 2024).

Describe the feature you'd like

The ability to create a Glossary, and have words that match the Glossary auto-underlined within a Book/Page, so when the user hovers over that underlined word, a small popup appears describing that item.

In the pictures below, you can see that the word "DNS" has been placed in the Glossary of this example website, and is auto-underlined wherever that word appears.
Definition - underlined

When you hover over that word, it then displays the definition for the user:
Definition - expanded

(In the example pics, there's also a note of who created the dictionary entry, as well as the option to vote on the description. I don't care about that portion of the feature - others might though - but in general, the ability to define a list of words you'd want to provide a definition for, and hover over the word to read the definition).

Because BookStack is so versatile, it would be highly limiting to assign definitions globally for the server.
For example, one book may have DNS as Domain Name System, and another book may have DNS as Distributed Normal Slouchiness. :), I'm thinking either a definition list per book (though that seems like a lot of redundant work), or (ideally) a section within BookStack to build/manage glossary terms (naming each glossary), and when you edit the book, you can apply one (or many) to that book. The reason for "many" is that you could have a Glossary of "technical terms" and apply it to the book, but then you could also have another glossary for a product-specific item, and apply it to the book as well. Whereas if you could only apply one, you may end up having to create duplicate terms in several glossaries. By being able to assign multiple, you could create very specific glossaries for sub-sections of an item, and all those that apply to the book, then get assigned.

Describe the benefits this would bring to existing BookStack users

This would allow the user to get detailed descriptions of the meaning of a word without having to leave the site.

Can the goal of this request already be achieved via other means?

As far as I know, no such function exists within BookStack that I have found or read in documentation.

Have you searched for an existing open/closed issue?

  • I have searched for existing issues and none cover my fundamental request

How long have you been using BookStack?

Under 3 months

Additional context

WIKI does something similar to this, by having clickable links instead. I personally don't like the clickable links, because they look like off-page resources. The underline option, to me, is much cleaner.

Originally created by @kristiandg on GitHub (Nov 11, 2024). ### Describe the feature you'd like The ability to create a Glossary, and have words that match the Glossary auto-underlined within a Book/Page, so when the user hovers over that underlined word, a small popup appears describing that item. In the pictures below, you can see that the word "DNS" has been placed in the Glossary of this example website, and is auto-underlined wherever that word appears. <img width="394" alt="Definition - underlined" src="https://github.com/user-attachments/assets/7555ed89-600f-4ad5-938c-dd32d6d07457"> When you hover over that word, it then displays the definition for the user: <img width="502" alt="Definition - expanded" src="https://github.com/user-attachments/assets/7e901be3-637a-4dea-8ca9-9a0a1eaf53f2"> (In the example pics, there's also a note of who created the dictionary entry, as well as the option to vote on the description. I don't care about that portion of the feature - others might though - but in general, the ability to define a list of words you'd want to provide a definition for, and hover over the word to read the definition). Because BookStack is so versatile, it would be highly limiting to assign definitions globally for the server. For example, one book may have DNS as Domain Name System, and another book may have DNS as Distributed Normal Slouchiness. :), I'm thinking either a definition list per book (though that seems like a lot of redundant work), or (ideally) a section within BookStack to build/manage glossary terms (naming each glossary), and when you edit the book, you can apply one (or many) to that book. The reason for "many" is that you could have a Glossary of "technical terms" and apply it to the book, but then you could also have another glossary for a product-specific item, and apply it to the book as well. Whereas if you could only apply one, you may end up having to create duplicate terms in several glossaries. By being able to assign multiple, you could create very specific glossaries for sub-sections of an item, and all those that apply to the book, then get assigned. ### Describe the benefits this would bring to existing BookStack users This would allow the user to get detailed descriptions of the meaning of a word without having to leave the site. ### Can the goal of this request already be achieved via other means? As far as I know, no such function exists within BookStack that I have found or read in documentation. ### Have you searched for an existing open/closed issue? - [X] I have searched for existing issues and none cover my fundamental request ### How long have you been using BookStack? Under 3 months ### Additional context WIKI does something similar to this, by having clickable links instead. I personally don't like the clickable links, because they look like off-page resources. The underline option, to me, is much cleaner.
OVERLORD added the 🔨 Feature Request label 2026-02-05 09:36:14 +03:00
Author
Owner

@ssddanbrown commented on GitHub (Nov 11, 2024):

Thanks for the request @kristiandg, but I would consider this a duplicate of #428 so will likely close this off.

It's probably possible to create a JavaScript hack though which looks up from a specific glossary page(s) for display in the desired manner. If desired, I could maybe whip a quick example but it just won't be officially supported at all.

@ssddanbrown commented on GitHub (Nov 11, 2024): Thanks for the request @kristiandg, but I would consider this a duplicate of #428 so will likely close this off. It's probably possible to create a JavaScript hack though which looks up from a specific glossary page(s) for display in the desired manner. If desired, I could maybe whip a quick example but it just won't be officially supported at all.
Author
Owner

@kristiandg commented on GitHub (Nov 12, 2024):

I never thought of that. I’m also not skilled in coding but would be very appreciative if you could. That would be wonderful. On Nov 11, 2024, at 2:55 PM, Dan Brown @.***> wrote:
Thanks for the request @kristiandg, but I would consider this a duplicate of #428 so will likely close this off.
It's probably possible to create a JavaScript hack though which looks up from a specific glossary page(s) for display in the desired manner. If desired, I could maybe whip a quick example but it just won't be officially supported at all.

—Reply to this email directly, view it on GitHub, or unsubscribe.You are receiving this because you were mentioned.Message ID: @.***>

@kristiandg commented on GitHub (Nov 12, 2024): I never thought of that. I’m also not skilled in coding but would be very appreciative if you could. That would be wonderful. On Nov 11, 2024, at 2:55 PM, Dan Brown ***@***.***> wrote: Thanks for the request @kristiandg, but I would consider this a duplicate of #428 so will likely close this off. It's probably possible to create a JavaScript hack though which looks up from a specific glossary page(s) for display in the desired manner. If desired, I could maybe whip a quick example but it just won't be officially supported at all. —Reply to this email directly, view it on GitHub, or unsubscribe.You are receiving this because you were mentioned.Message ID: ***@***.***>
Author
Owner

@ssddanbrown commented on GitHub (Nov 12, 2024):

Okay, here's a basic example I've spent some time on.
To keep the scope reasonable and to limit spending loads of time, there's some important limitations:

  • The term popups are simple, with no extra title, just the definition.
  • The term matches are word-based, so doesn't work as-is against multiple words (would be possible but more complicated to build and less performant in-app).

Here's the code:

View Code

<script type="module">
    const defaultGlossaryPage = '/books/my-book/page/main-glossary';

    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
        addTermMapToIndex(urlPath, textToTermMap(pageContentEl.textContent));
    } else if (pageContentEl) {
        // Get glossaries and highlight when viewing non-glossary pages
        setTimeout(() => {
            highlightTermsOnPage();
        }, 5);
    }

    async function highlightTermsOnPage() {
        const glossary = await getMergedGlossariesForPage(urlPath);
        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 glossaryVal = glossary[word.toLowerCase()];
                if (glossaryVal) {
                    const preText = parsedWords.join(' ');
                    const preTextNode = new Text((firstChange ? '' : ' ') + preText + ' ');
                    parent.insertBefore(preTextNode, node)
                    const termEl = document.createElement('span');
                    termEl.setAttribute('data-term', glossaryVal.trim());
                    termEl.setAttribute('class', 'glossary-term');
                    termEl.textContent = word;
                    parent.insertBefore(termEl, node);
                    node.textContent = node.textContent.replace(preText + ' ' + word, '');
                    parsedWords = [];
                    firstChange = false;
                    continue;
                }

                parsedWords.push(word);
            }
        }
    }

    /**
     * @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);
    }

    /**
     * @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);
    }

    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');
            map = textToTermMap(contentEl?.textContent || '');
        }

        addTermMapToIndex(path, map);
        return map;
    }

    /**
     * @param {String} urlPath
     * @param {Object<string, string>} map
     */
    function addTermMapToIndex(urlPath, map) {
        window.localStorage.setItem('bsglossary:' + urlPath, JSON.stringify(map));
    }

    /**
     * Convert the given text into a map of definitions by term.
     * @param {string} text
     * @return {Object<string, string>}
     */
    function textToTermMap(text) {
        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>
    .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>

Add all of that the the "Custom HTML Head Content" customization setting.
This will look up to a global glossary, defined via a single page on line 2 of the code (tweak this URL to the path of your own global glossary page, but keep the path format the same) while also looking up a page of URL slug (typically name) Glossary in the book for the current page you're viewing.

Glossary pages should have their content written like so:

Term: Description for term

# Example
VPS: A virtual private server (VPS) is a virtual machine sold as a service by an Internet hosting service.

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

Terms are matched case-insensitive.
Glossaries are cached in the browser to prevent needing a lookup on each page load. They can be force reloaded by simply viewing the changed glossary page.

Note: there may be further bugs and limitations of this, I've only quickly tested and this if all unofficial hackery.

Let me know if that works for you. If it does, I may clean it up and add it to our hacks site for others to easily use.

@ssddanbrown commented on GitHub (Nov 12, 2024): Okay, here's a basic example I've spent some time on. To keep the scope reasonable and to limit spending loads of time, there's some important limitations: - The term popups are simple, with no extra title, just the definition. - The term matches are word-based, so doesn't work as-is against multiple words (would be possible but more complicated to build and less performant in-app). Here's the code: <details><summary>View Code</summary> <p> ```html <script type="module"> const defaultGlossaryPage = '/books/my-book/page/main-glossary'; 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 addTermMapToIndex(urlPath, textToTermMap(pageContentEl.textContent)); } else if (pageContentEl) { // Get glossaries and highlight when viewing non-glossary pages setTimeout(() => { highlightTermsOnPage(); }, 5); } async function highlightTermsOnPage() { const glossary = await getMergedGlossariesForPage(urlPath); 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 glossaryVal = glossary[word.toLowerCase()]; if (glossaryVal) { const preText = parsedWords.join(' '); const preTextNode = new Text((firstChange ? '' : ' ') + preText + ' '); parent.insertBefore(preTextNode, node) const termEl = document.createElement('span'); termEl.setAttribute('data-term', glossaryVal.trim()); termEl.setAttribute('class', 'glossary-term'); termEl.textContent = word; parent.insertBefore(termEl, node); node.textContent = node.textContent.replace(preText + ' ' + word, ''); parsedWords = []; firstChange = false; continue; } parsedWords.push(word); } } } /** * @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); } /** * @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); } 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'); map = textToTermMap(contentEl?.textContent || ''); } addTermMapToIndex(path, map); return map; } /** * @param {String} urlPath * @param {Object<string, string>} map */ function addTermMapToIndex(urlPath, map) { window.localStorage.setItem('bsglossary:' + urlPath, JSON.stringify(map)); } /** * Convert the given text into a map of definitions by term. * @param {string} text * @return {Object<string, string>} */ function textToTermMap(text) { 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> .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> ``` </p> </details> Add all of that the the "Custom HTML Head Content" customization setting. This will look up to a global glossary, defined via a single page on line 2 of the code (**tweak this URL to the path of your own global glossary page, but keep the path format the same**) while also looking up a page of URL slug (typically name) `Glossary` in the book for the current page you're viewing. Glossary pages should have their content written like so: ``` Term: Description for term # Example VPS: A virtual private server (VPS) is a virtual machine sold as a service by an Internet hosting service. Cat: A type of small house tiger that constantly demands food. ``` Terms are matched case-insensitive. Glossaries are cached in the browser to prevent needing a lookup on each page load. They can be force reloaded by simply viewing the changed glossary page. Note: there may be further bugs and limitations of this, I've only quickly tested and this if all unofficial hackery. Let me know if that works for you. If it does, I may clean it up and add it to our hacks site for others to easily use.
Author
Owner

@kristiandg commented on GitHub (Nov 12, 2024):

@ssddanbrown , THANK YOU SO MUCH!!!

I want to make sure I'm doing the URL replacement properly.
const urlPath = window./books/global-glossary(/^.*?\/books\//, '/books/');

Is the above correct, or do I need the full Server URL (since the rest appears to be coded as relative path, I figured this would work).
In that book, I created a page called "glossary" and built one test entry:
Screenshot 2024-11-12 at 9 08 03 AM

Permission-wise, I have the access to view that Glossary page, so that shouldn't be a limiting factor.

Here's where I pasted your code:
Screenshot 2024-11-12 at 9 10 11 AM

In my test book, I've used the term VPN in a couple of locations, but am not seeing it bubble up or highlight. I have no doubt I'm doing something wrong.

@kristiandg commented on GitHub (Nov 12, 2024): @ssddanbrown , THANK YOU SO MUCH!!! I want to make sure I'm doing the URL replacement properly. ` const urlPath = window./books/global-glossary(/^.*?\/books\//, '/books/');` Is the above correct, or do I need the full Server URL (since the rest appears to be coded as relative path, I figured this would work). In that book, I created a page called "glossary" and built one test entry: <img width="851" alt="Screenshot 2024-11-12 at 9 08 03 AM" src="https://github.com/user-attachments/assets/c8a16a8b-e932-47a6-b61c-6a563e6cc93f"> Permission-wise, I have the access to view that Glossary page, so that shouldn't be a limiting factor. Here's where I pasted your code: <img width="807" alt="Screenshot 2024-11-12 at 9 10 11 AM" src="https://github.com/user-attachments/assets/7112dde1-b26f-4d76-a148-e910afad3b2b"> In my test book, I've used the term VPN in a couple of locations, but am not seeing it bubble up or highlight. I have no doubt I'm doing something wrong.
Author
Owner

@ssddanbrown commented on GitHub (Nov 12, 2024):

Is the above correct,

No, sorry, I should have been more clear, it's line 2 overall, so the value of the defaultGlossaryPage variable.
Edit: To add more context, you just need to change the my-book and main-glossary parts of that text to match the URL sub-path for your main global/default/fallback glossary page.
Should not need to touch any other lines.

@ssddanbrown commented on GitHub (Nov 12, 2024): > Is the above correct, No, sorry, I should have been more clear, it's line 2 overall, so the value of the `defaultGlossaryPage` variable. Edit: To add more context, you just need to change the `my-book` and `main-glossary` parts of that text to match the URL sub-path for your main global/default/fallback glossary page. Should not need to touch any other lines.
Author
Owner

@kristiandg commented on GitHub (Nov 12, 2024):

Oh, for god sakes. LOL. You were crystal clear (line 2 - line 2 - jeez). I just hadn't had my coffee yet.

Works beautifully. THANK YOU SO MUCH.

Even the nice little touch with the cursor becoming a ? - VERY NICE. :)

@kristiandg commented on GitHub (Nov 12, 2024): Oh, for god sakes. LOL. You were crystal clear (line 2 - line 2 - jeez). I just hadn't had my coffee yet. Works beautifully. THANK YOU SO MUCH. Even the nice little touch with the cursor becoming a ? - VERY NICE. :)
Author
Owner

@JNR8 commented on GitHub (Nov 20, 2024):

After implementting a global glossary to test this out I have come across a few issues:

1: Terms in a header are highlighted, when perhaps they should not be.
2: The term being highlighted is duplicated (but not all the time, see attached image).
image

3: Terms are not always displayed. Different browsers have different results. MS Edge for example throws an exception:

Uncaught (in promise) QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'bsglossary:/books/global-glossary/page/index' exceeded the quota.
    at addTermMapToStorage (9th-to-8th-kyu-red:193:29)
    at getGlossaryFromPath (9th-to-8th-kyu-red:183:9)
    at async Promise.all (index 0)
    at async getMergedGlossariesForPage (9th-to-8th-kyu-red:135:49)
    at async highlightTermsOnPage (9th-to-8th-kyu-red:85:26)
@JNR8 commented on GitHub (Nov 20, 2024): After implementting a global glossary to test this out I have come across a few issues: 1: Terms in a header are highlighted, when perhaps they should not be. 2: The term being highlighted is duplicated (but not all the time, see attached image). ![image](https://github.com/user-attachments/assets/e3e9948f-c594-41e0-94f5-4612ef24fdd0) 3: Terms are not always displayed. Different browsers have different results. MS Edge for example throws an exception: ```` Uncaught (in promise) QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'bsglossary:/books/global-glossary/page/index' exceeded the quota. at addTermMapToStorage (9th-to-8th-kyu-red:193:29) at getGlossaryFromPath (9th-to-8th-kyu-red:183:9) at async Promise.all (index 0) at async getMergedGlossariesForPage (9th-to-8th-kyu-red:135:49) at async highlightTermsOnPage (9th-to-8th-kyu-red:85:26) ````
Author
Owner

@kristiandg commented on GitHub (Jan 12, 2025):

I did just find an oddity on this... I got around the "single word" thing by simply smashing the multiple words together for a glossary (such as "Call Queue" becomes "CallQueue". But I noticed this (and it doesn't happen on every occurrence of the word):

Original text (during editing):
BookStack - Editing Page

Modified text (during viewing):
BookStack - Viewing Page

Here's the relevant section of the Glossary:
BookStack - Definitions

It's odd, I'm not sure why it's adding "NightMode" onto the end of that particular definition, but it's not doing it all the time, doing that.

It did it one other time in the document (inside of a table, it doubled up on it's "plural" version of the definition:
BookStack - Other Double

I also noticed, when Glossary finds a match in a Callout, the floating window is constrained to the size of the callout window, making it now scroll (this one's kinda funny):
BookStack - Callout

@kristiandg commented on GitHub (Jan 12, 2025): I did just find an oddity on this... I got around the "single word" thing by simply smashing the multiple words together for a glossary (such as "Call Queue" becomes "CallQueue". But I noticed this (and it doesn't happen on every occurrence of the word): Original text (during editing): <img width="885" alt="BookStack - Editing Page" src="https://github.com/user-attachments/assets/b8a1f00e-70b8-4a8b-a598-f53739f55bd5" /> Modified text (during viewing): <img width="862" alt="BookStack - Viewing Page" src="https://github.com/user-attachments/assets/02880fa8-c90a-4fbb-9788-a5acb6662de2" /> Here's the relevant section of the Glossary: <img width="846" alt="BookStack - Definitions" src="https://github.com/user-attachments/assets/9824d3cd-71f1-49c7-b5bc-a5871404b78f" /> It's odd, I'm not sure why it's adding "NightMode" onto the end of that particular definition, but it's not doing it all the time, doing that. It did it one other time in the document (inside of a table, it doubled up on it's "plural" version of the definition: <img width="191" alt="BookStack - Other Double" src="https://github.com/user-attachments/assets/e51dbed6-0894-4610-a3c6-078fa08d4577" /> I also noticed, when Glossary finds a match in a Callout, the floating window is constrained to the size of the callout window, making it now scroll (this one's kinda funny): <img width="782" alt="BookStack - Callout" src="https://github.com/user-attachments/assets/7deca2d0-551b-49c7-b667-ba27c93877d8" />
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: starred/BookStack#5043