Source: app.js

// Licensed under the MIT License
// https://github.com/craigahobbs/markdown-up/blob/main/LICENSE

/** @module lib/app */

import {MarkdownScriptRuntime, markdownScriptCodeBlock} from './script.js';
import {decodeQueryString, encodeQueryString, jsonStringifySortKeys} from 'schema-markdown/lib/encode.js';
import {getMarkdownTitle, parseMarkdown} from 'markdown-model/lib/parser.js';
import {evaluateExpression} from 'bare-script/lib/runtime.js';
import {markdownElementsAsync} from 'markdown-model/lib/elements.js';
import {markdownScriptFunctions} from './scriptLibrary.js';
import {parseExpression} from 'bare-script/lib/parser.js';
import {parseSchemaMarkdown} from 'schema-markdown/lib/parser.js';
import {renderElements} from 'element-model/lib/elementModel.js';
import {schemaMarkdownDoc} from 'schema-markdown-doc/lib/schemaMarkdownDoc.js';
import {validateType} from 'schema-markdown/lib/schema.js';


// The application's hash parameter type model
const markdownUpTypes = parseSchemaMarkdown(`\
#
# [MarkdownUp](https://github.com/craigahobbs/markdown-up#readme) is a Markdown viewer.
#
# ## Hash Parameters
#
# MarkdownUp recognizes the following hash parameters:
#
struct MarkdownUp

    # The resource URL
    optional string url

    # Variable expressions
    optional string{} var

    # Optional command
    optional MarkdownUpView view


# The MarkdownUp local storage JSON schema
struct MarkdownUpLocal

    # If set, dark mode is enabled
    optional int(== 1) darkMode

    # The font size
    optional int(>= 8, <= 18) fontSize

    # The line height
    optional float(>= 1, <= 2) lineHeight


# The MarkdownUp session storage JSON schema
struct MarkdownUpSession

    # If set, the menu is expanded (default is collapsed)
    optional int(== 1) menu

    # If set, enable debug behavior
    optional int(== 1) debug


# A MarkdownUp application view
enum MarkdownUpView

    # Display the hash parameters documentation
    help

    # Show the resource's Markdown text
    markdown
`);


/**
 * The MarkdownUp application options
 *
 * @typedef {Object} MarkdownUpOptions
 * @property {boolean} [darkMode = false] - If true, use dark mode by default
 * @property {number} [fontSize = 12] - The font size, in points
 * @property {?Object} [globals = null] - Global script runtime variables
 * @property {number} [lineHeight = 1.2] - The line height, in em
 * @property {?string} [markdownText = null] - The default Markdown text
 * @property {boolean} [menu = true] - If true, show the menu
 * @property {string} [systemPrefix] - The markdown-script system include prefix
 * @property {string} [url = 'README.md'] - The default resource URL
 */


/**
 * The MarkdownUp application
 */
export class MarkdownUp {
    /**
     * Create an application instance
     *
     * @param {Object} window - The web browser window object
     * @param {Object} [options] - The [application options]{@link module:lib/app~MarkdownUpOptions}
     */
    constructor(window, options = null) {
        this.window = window;
        this.params = null;
        this.paramsLocal = null;
        this.paramsSession = null;
        this.darkMode = (options !== null ? options.darkMode : null) ?? false;
        this.fontSize = (options !== null ? options.fontSize : null) ?? 12;
        this.globals = (options !== null ? options.globals : null) ?? null;
        this.lineHeight = (options !== null ? options.lineHeight : null) ?? 1.2;
        this.markdownText = (options !== null ? options.markdownText : null) ?? null;
        this.menu = (options !== null ? options.menu : null) ?? true;
        this.systemPrefix = (options !== null ? options.systemPrefix : null) ?? 'https://craigahobbs.github.io/markdown-up/include/';
        this.url = (options !== null ? options.url : null) ?? 'README.md';
        this.runtimeWindowResize = null;
        this.runtimeTimeoutId = null;
    }


    /**
     * Run the application
     */
    async run() {
        await this.render();
        this.window.addEventListener('hashchange', () => this.render(), false);
    }


    async render(forceRender = false) {
        // Parse the hash parameters and render the application element model
        let result;
        let isError = false;
        try {
            // Validate hash parameters
            const paramsPrev = this.params;
            this.updateParams();

            // Skip the render if the page params haven't changed
            if (!forceRender && paramsPrev !== null && jsonStringifySortKeys(paramsPrev) === jsonStringifySortKeys(this.params)) {
                return;
            }
        } catch ({message}) {
            result = {
                'title': 'MarkdownUp',
                'elements': {'html': 'p', 'elem': {'text': `Error: ${message}`}}
            };
            isError = true;
        }

        // Set the colors
        const isDarkMode = this.paramsLocal.darkMode ?? this.darkMode;
        this.window.document.documentElement.style.setProperty('--markdown-model-dark-mode', isDarkMode ? '1' : '0');

        // Set the font size
        const fontSize = this.paramsLocal.fontSize ?? this.fontSize;
        this.window.document.documentElement.style.setProperty('--markdown-model-font-size', `${fontSize}pt`);

        // Set the line height
        const lineHeight = this.paramsLocal.lineHeight ?? this.lineHeight;
        this.window.document.documentElement.style.setProperty('--markdown-model-line-height', `${lineHeight}em`);

        // Call the application main and validate the result
        this.clearRuntimeCallbacks();
        if (!isError) {
            result = await this.main();
        }

        // Set the window title
        const title = result.title ?? null;
        if (title !== null) {
            this.window.document.title = title;
        }

        // Timeout?
        if ('timeout' in result) {
            this.runtimeTimeoutId = this.window.setTimeout(...result.timeout);
        }

        // Window resize event handler?
        if ('resize' in result) {
            this.runtimeWindowResize = result.resize;
            this.window.addEventListener('resize', result.resize);
        }

        // Render the element model
        renderElements(this.window.document.body, result.elements);

        // Focus?
        if ('focus' in result) {
            this.setDocumentFocus(result.focus);
        }

        // Navigate?
        // Note: This is done after render since it may have no effect (in which case we need to render)
        if ('location' in result && result.location !== null) {
            this.window.location.href = result.location;
            return;
        }

        // If there is a header ID, re-navigate to scroll to the header ID since it was just rendered.
        // The re-render is short-circuited by the unchanged hash param check above.
        if (!isError && getHeaderId(this.window.location.hash) !== null) {
            this.window.location.href = this.window.location.hash;
        }
    }


    updateParams(paramString = null, localJSONString = null, sessionJSONString = null) {
        // Clear, then validate the hash parameters (may throw)
        this.params = null;
        this.paramsLocal = {};
        this.paramsSession = {};

        // Decode and validate the hash parameters
        this.params = validateType(
            markdownUpTypes,
            'MarkdownUp',
            decodeQueryString(paramString ?? this.window.location.hash.slice(1))
        );

        // Decode and validate the local storage paramters
        const localJSON = localJSONString ?? this.window.localStorage.getItem('MarkdownUp');
        if (localJSON !== null) {
            try {
                this.paramsLocal = validateType(markdownUpTypes, 'MarkdownUpLocal', JSON.parse(localJSON));
            } catch {
                // Do nothing
            }
        }

        // Decode and validate the session storage parameters
        const sessionJSON = sessionJSONString ?? this.window.sessionStorage.getItem('MarkdownUp');
        if (sessionJSON !== null) {
            try {
                this.paramsSession = validateType(markdownUpTypes, 'MarkdownUpSession', JSON.parse(sessionJSON));
            } catch {
                // Do nothing
            }
        }
    }


    clearRuntimeCallbacks(clearResize = true) {
        // Clear the runtime timeout ID, if one is set
        if (this.runtimeTimeoutId !== null) {
            this.window.clearTimeout(this.runtimeTimeoutId);
            this.runtimeTimeoutId = null;
        }

        // Clear the window resize event handler
        if (clearResize && this.runtimeWindowResize !== null) {
            this.window.removeEventListener('resize', this.runtimeWindowResize);
            this.runtimeWindowResize = null;
        }
    }


    setDocumentFocus(focusId) {
        const element = this.window.document.getElementById(focusId);
        if (element !== null) {
            element.focus();
            if ('selectionStart' in element) {
                element.selectionStart = element.value.length;
                element.selectionEnd = element.value.length;
            }
        }
    }


    async main() {
        // Help?
        if (this.params.view === 'help') {
            return {
                'title': 'MarkdownUp',
                'elements': [
                    this.burgerElements(),
                    schemaMarkdownDoc(markdownUpTypes, 'MarkdownUp', {'params': encodeQueryString(this.params)})
                ]
            };
        }

        // Get the Markdown text
        const urlOverride = 'url' in this.params && this.params.url !== '';
        const url = urlOverride ? this.params.url : this.url;
        const scriptOptions = this.createScriptOptions(url);
        let markdownText;
        let timeBegin;
        if (this.markdownText !== null && !urlOverride) {
            ({markdownText} = this);

            // Log Markdown render begin
            if ('logFn' in scriptOptions && scriptOptions.debug) {
                timeBegin = performance.now();
                scriptOptions.logFn('MarkdownUp: ===== Rendering Markdown text');
            }
        } else {
            // Log Markdown render begin
            if ('logFn' in scriptOptions && scriptOptions.debug) {
                scriptOptions.logFn(`MarkdownUp: ===== Rendering Markdown document "${url}"`);
            }

            // Log Markdown fetch begin
            let fetchBegin;
            if ('logFn' in scriptOptions && scriptOptions.debug) {
                fetchBegin = performance.now();
                scriptOptions.logFn(`MarkdownUp: Fetching "${url}" ...`);
            }

            // Fetch the Markdown text resource URL
            const response = await this.window.fetch(url);
            if (!response.ok) {
                const status = response.statusText;
                return {
                    'title': 'MarkdownUp',
                    'elements': {
                        'html': 'p',
                        'elem': {'text': `Error: Could not fetch "${url}"${status === '' ? '' : ` - ${JSON.stringify(status)}`}`}
                    }
                };
            }
            markdownText = await response.text();

            // Log Markdown fetch end with timing
            if ('logFn' in scriptOptions && scriptOptions.debug) {
                const fetchEnd = performance.now();
                timeBegin = performance.now();
                scriptOptions.logFn(`MarkdownUp: Fetch completed in ${(fetchEnd - fetchBegin).toFixed(1)} milliseconds`);
            }
        }

        // Parse the Markdown and get the title
        const markdownModel = parseMarkdown(markdownText);
        const markdownTitle = getMarkdownTitle(markdownModel);

        // Display the Markdown?
        if (this.params.view === 'markdown') {
            return {
                'title': markdownTitle,
                'elements': [
                    this.burgerElements(),
                    {'html': 'div', 'attr': {'class': 'markdown'}, 'elem': {'text': markdownText}}
                ]
            };
        }

        // Render the Markdown
        const result = {
            'title': markdownTitle,
            'elements': [
                // Menu "burger"
                this.burgerElements(),

                // Render the markdown
                await markdownElementsAsync(markdownModel, {
                    'codeBlocks': {
                        'markdown-script': (codeBlock) => markdownScriptCodeBlock(codeBlock, scriptOptions)
                    },
                    'urlFn': (urlRaw) => this.modifyURL(urlRaw, url),
                    'headerIds': true
                })
            ]
        };

        // Set any runtime side-effects
        if (scriptOptions.runtime.documentFocus !== null) {
            result.focus = scriptOptions.runtime.documentFocus;
        }
        if (scriptOptions.runtime.documentTitle !== null) {
            result.title = scriptOptions.runtime.documentTitle;
        }
        if (scriptOptions.runtime.windowLocation !== null) {
            result.location = scriptOptions.runtime.windowLocation;
        }
        if (scriptOptions.runtime.windowResize !== null) {
            result.resize = scriptOptions.runtime.windowResize;
        }
        if (scriptOptions.runtime.windowTimeout !== null) {
            result.timeout = scriptOptions.runtime.windowTimeout;
        }

        // Reset the runtime
        scriptOptions.runtime.reset();

        // Log Markdown render end
        if ('logFn' in scriptOptions && scriptOptions.debug) {
            const timeEnd = performance.now();
            scriptOptions.logFn(`MarkdownUp: Markdown rendered in ${(timeEnd - timeBegin).toFixed(1)} milliseconds`);
        }

        return result;
    }


    createScriptOptions(resourceURL) {
        const logFn = (text) => {
            this.window.console.log(text);
        };
        const scriptOptions = {
            'debug': 'debug' in this.paramsSession,
            // eslint-disable-next-line require-await
            'fetchFn': async (fetchURL, options) => this.window.fetch(fetchURL, options),
            'fontSize': this.paramsLocal.fontSize ?? this.fontSize,
            logFn,
            'params': this.params,
            'systemPrefix': this.systemPrefix,
            'urlFn': (url) => this.modifyURL(url, resourceURL),
            'window': this.window
        };

        // Add hash parameter variables, if any
        if ('var' in this.params) {
            scriptOptions.variables = {};
            for (const varName of Object.keys(this.params.var)) {
                const varExprStr = this.params.var[varName];
                try {
                    scriptOptions.variables[varName] = evaluateExpression(parseExpression(varExprStr), scriptOptions.variables);
                } catch ({message}) {
                    if (logFn !== null && scriptOptions.debug) {
                        logFn(`MarkdownUp: Error evaluating variable "${varName}" expression "${varExprStr}": ${message}`);
                    }
                }
            }
        }

        // Create the markdown-script runtime
        const runtime = new MarkdownScriptRuntime(scriptOptions);
        scriptOptions.globals = {...markdownScriptFunctions};
        if (this.globals !== null) {
            Object.assign(scriptOptions.globals, this.globals);
        }
        scriptOptions.runtime = runtime;
        scriptOptions.runtimeUpdateFn = () => {
            // Did script set the document title?
            if (runtime.documentTitle !== null) {
                this.window.document.title = runtime.documentTitle;
            }

            // Did the script set a timeout?
            this.clearRuntimeCallbacks(false);
            if (runtime.windowTimeout !== null) {
                this.runtimeTimeoutId = this.window.setTimeout(...runtime.windowTimeout);
            }

            // Reset the runtime and render
            const elements = runtime.resetElements();
            if (elements !== null) {
                const {body} = this.window.document;
                if (runtime.documentReset === null) {
                    renderElements(body, [this.burgerElements(), elements]);
                } else {
                    while (body.lastChild !== null && body.lastChild.id !== runtime.documentReset) {
                        body.lastChild.remove();
                    }
                    renderElements(body, [elements], false);
                }
            }

            // Focus?
            if (runtime.documentFocus !== null) {
                this.setDocumentFocus(runtime.documentFocus);
            }

            // Navigate?
            // Note: This is done after render since it may have no effect (in which case we want to render).
            if (runtime.windowLocation !== null) {
                this.window.location.href = runtime.windowLocation;
            }

            // Reset the runtime
            runtime.reset();
        };

        return scriptOptions;
    }


    modifyURL(url, resourceURL) {
        // Hash URL?
        if (url.startsWith('#')) {
            // Fixup the "url" param if its relative
            const hashParams = decodeQueryString(url.slice(1));
            if ('url' in hashParams && hashParams.url !== '' && isRelativeURL(hashParams.url)) {
                hashParams.url = `${getBaseURL(resourceURL)}${hashParams.url}`;
            }

            // Encode hash parameters with a header ID
            const paramString = encodeQueryString({...this.params, ...hashParams});
            const headerId = getHeaderId(url);
            return `#${paramString}${headerId !== null && paramString !== '' ? '&' : ''}${headerId !== null ? headerId : ''}`;
        }

        // Relative URL?
        if (isRelativeURL(url)) {
            return `${getBaseURL(resourceURL)}${url}`;
        }

        return url;
    }


    burgerElements() {
        const paramString = encodeQueryString(this.params);
        const topHeaderId = `${paramString}${paramString !== '' ? '&' : ''}_top`;
        return [
            !this.menu ? null : [
                {
                    'html': 'div',
                    'attr': {'class': 'menu-burger'},
                    'elem': this.menuValueToggle('menu', {
                        'size': 32,
                        'noCheck': true,
                        'path': 'M3,5 L21,5 M3,12 L21,12 M3,19 L21,19'
                    })
                },

                // Popup menu
                !('menu' in this.paramsSession) ? null : {
                    'html': 'div',
                    'attr': {'class': 'menu'},
                    'elem': [
                        this.menuViewToggle('markdown', {
                            'path': 'M4,2 L20,2 L20,22 L4,22 Z',
                            'path2': 'M7,7.5 L17,7.5 M7,12 L17,12 M7,16.5 L17,16.5'
                        }),
                        this.menuValueToggle('darkMode', {
                            'path': 'M16,3 A10,10,0,1,1,3,18 A14,14,0,0,0,17,3'
                        }, true),
                        this.menuValueCycle('fontSize', this.fontSize, 2, {
                            'path': 'M4,22 L10,2 L14,2 L20,22 M6,14 L18,14',
                            'strokeWidth': 4
                        }),
                        this.menuValueCycle('lineHeight', this.lineHeight, 0.2, {
                            'path2': 'M2,3 L22,3 M2,9 L22,9 M2,15 L22,15 M2,21 L22,21'
                        }),
                        this.menuValueToggle('debug', {
                            'path': 'M12,5 A4,7,0,1,0,12,19 A4,7,0,1,0,12,5 M9,9 L15,9 M9,9 L4,6 M9,12 L3,12 M9,15 L4,18 ' +
                                'M15,9 L20,6 M15,12 L21,12 M15,15 L20,18'
                        }),
                        this.menuViewToggle('help', {
                            'path': 'M7,9 L7,4 L17,4 L17,12 L12,12 L12,16 M12,19 L12,22'
                        })
                    ]
                }
            ],

            // The top header ID
            {'html': 'div', 'attr': {'id': topHeaderId, 'style': 'display=none; position: absolute; top: 0;'}}
        ];
    }


    menuValueToggle(valueName, icon, isLocal = false) {
        const params = (isLocal ? this.paramsLocal : this.paramsSession);
        icon.checked = valueName in params;
        return this.menuButton(
            () => {
                if (valueName in params) {
                    delete params[valueName];
                } else {
                    params[valueName] = 1;
                }
                const storage = (isLocal ? this.window.localStorage : this.window.sessionStorage);
                storage.setItem('MarkdownUp', JSON.stringify(params));
                this.render(true);
            },
            icon
        );
    }


    menuViewToggle(viewValue, icon) {
        icon.checked = this.params.view === viewValue;
        return this.menuButton(
            () => {
                const params = {...this.params};
                if (params.view === viewValue) {
                    delete params.view;
                } else {
                    params.view = viewValue;
                }
                this.window.location.href = `#${encodeQueryString(params)}`;
            },
            icon
        );
    }


    menuValueCycle(valueName, valueDefault, valueDelta, icon) {
        return this.menuButton(
            () => {
                const {attr} = markdownUpTypes.MarkdownUpLocal.struct.members.find((member) => member.name === valueName);
                let valueNew = (this.paramsLocal[valueName] ?? valueDefault) + valueDelta;
                if (valueNew > attr.lte) {
                    valueNew = attr.gte;
                }
                this.paramsLocal[valueName] = valueNew;
                this.window.localStorage.setItem('MarkdownUp', JSON.stringify(this.paramsLocal));
                this.render(true);
            },
            icon
        );
    }


    menuButton(onClick, {checked = false, noCheck = false, path = null, path2 = null, strokeWidth = 3, size = 48}) {
        const isDarkMode = this.paramsLocal.darkMode ?? this.darkMode;
        const stroke = (isDarkMode ? (checked && !noCheck ? 'black' : 'white') : (checked && !noCheck ? 'white' : 'black'));
        const borderSize = (0.125 * size).toFixed(3);
        const innerSize = (0.75 * size).toFixed(3);
        return {
            'html': 'div',
            'attr': {'style': 'cursor: pointer; user-select: none;'},
            'elem': {
                'svg': 'svg',
                'attr': {'width': size, 'height': size},
                'elem': [
                    !checked || noCheck ? null : {
                        'svg': 'rect',
                        'attr': {'fill': (isDarkMode ? 'white' : 'black'), 'stroke': 'none', 'width': size, 'height': size}
                    },
                    {
                        'svg': 'g',
                        'attr': {'transform': `translate(${borderSize}, ${borderSize})`},
                        'elem': {
                            'svg': 'svg',
                            'attr': {'width': innerSize, 'height': innerSize, 'viewBox': '0 0 24 24'},
                            'elem': [
                                path === null ? null : {
                                    'svg': 'path',
                                    'attr': {'fill': 'none', 'stroke': stroke, 'stroke-width': strokeWidth, 'd': path}
                                },
                                path2 === null ? null : {
                                    'svg': 'path',
                                    'attr': {'fill': 'none', 'stroke': stroke, 'stroke-width': strokeWidth - 1, 'd': path2}
                                }
                            ]
                        }
                    }
                ]
            },
            'callback': (element) => {
                element.addEventListener('click', onClick);
            }
        };
    }
}


// Get a URL's header ID
function getHeaderId(url) {
    const matchId = url.match(rHeaderId);
    return matchId !== null ? matchId[1] : null;
}

const rHeaderId = /[#&]([^=]+)$/;


// Test if a URL is relative
function isRelativeURL(url) {
    return !rNotRelativeURL.test(url);
}

const rNotRelativeURL = /^(?:[a-z]+:|\/|\?|#)/;


// Get a URL's base URL
function getBaseURL(url) {
    return url.slice(0, url.lastIndexOf('/') + 1);
}