]> BookStack Code Mirror - hacks/commitdiff
Added glossary hack
authorDan Brown <redacted>
Wed, 13 Nov 2024 16:21:58 +0000 (16:21 +0000)
committerDan Brown <redacted>
Wed, 13 Nov 2024 16:21:58 +0000 (16:21 +0000)
content/dynamic-glossary/head.html [new file with mode: 0644]
content/dynamic-glossary/index.md [new file with mode: 0644]

diff --git a/content/dynamic-glossary/head.html b/content/dynamic-glossary/head.html
new file mode 100644 (file)
index 0000000..85028fb
--- /dev/null
@@ -0,0 +1,193 @@
+<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);
+                    node.textContent = node.textContent.replace(preText + ' ' + word, '');
+                    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>
\ No newline at end of file
diff --git a/content/dynamic-glossary/index.md b/content/dynamic-glossary/index.md
new file mode 100644 (file)
index 0000000..2233f70
--- /dev/null
@@ -0,0 +1,46 @@
++++
+title = "Dynamic Glossary"
+author = "@ssddanbrown"
+date = 2024-11-13T16:20:00Z
+updated = 2024-11-13T16:20:00Z
+tested = "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:
+
+```
+Term: Description for term
+```
+
+For example:
+
+```
+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
+
+{{<hack file="head.html" type="head">}}