Skip to main content

Dynamic Glossary

<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/glossar/page/glossar';

    // 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/glossar') || 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: 9999;
        top: 2em;
        inset-inline-start: 0;
        display: none;
    }
    .dark-mode .page-content .glossary-term:after {
        background-color: #000;
    }
    .page-content table td, .page-content table th {
        position: relative;
        overflow: visible;
    }
</style>