• File: themestrap.plugin.markdown-20261026181527-20261026210450.js
  • Full Path: /home/daemondevin/public_html/portableapps/assets/admin/themestrap/themestrap.plugin.markdown-20261026181527-20261026210450.js
  • Date Modified: 10/17/2026 7:00 PM
  • File size: 34.89 KB
  • MIME-type: text/html
  • Charset: utf-8
/**
 * Themestrap GFM Markdown Plugin
 * Parses GitHub Flavored Markdown into HTML, entirely client-side, with no
 * external parser dependency.  A lightweight GFM-compatible tokenizer/renderer
 * is bundled inside the IIFE so the plugin is self-contained.
 *
 * Part of the Themestrap component library.
 *
 * ATTRIBUTE API  (all attributes on the host element)
 *
 *   data-plugin-markdown
 *     Presence triggers auto-init. Value is ignored.
 *
 *   data-plugin-markdown-src="url/to/file.md"
 *     Fetch Markdown from a URL via XHR and render into the host element.
 *     When absent the plugin renders the element's existing text content.
 *
 *   data-plugin-options='{"sanitize":true,"breaks":true,"taskLists":true}'
 *     Standard Themestrap options bag; merges with PluginMarkdown.defaults.
 * 
 * SYNTAX HIGHLIGHTING  (PluginHighlight integration)
 *
 *   When options.highlight is true (default) and PluginHighlight is loaded on
 *   the page, every fenced code block rendered by the plugin is automatically
 *   passed to $.fn.themestrapPluginHighlight().  The language is read from the
 *   class="language-*" attribute that the GFM renderer emits — no extra markup
 *   is needed.
 *
 *   If PluginHighlight is not loaded the option is silently ignored and code
 *   blocks retain their plain <pre><code> markup.
 *
 *   Options forwarded to PluginHighlight can be set via options.highlightOptions:
 *
 *     $('#readme').themestrapPluginMarkdown({
 *         highlight: true,
 *         highlightOptions: { lineNumbers: true, showCopy: true }
 *     });
 *
 *   Per-block overrides via data-plugin-options on the rendered <pre> are NOT
 *   supported (the <pre> is generated by the GFM renderer, not authored by the
 *   user).  Use highlightOptions for page-wide defaults.
 *
 *   Highlight instances are tracked in this._highlightInstances and are
 *   destroyed alongside the Markdown instance when destroy() is called.
 *
 * SUPPORTED GFM FEATURES
 *
 *   Block:  ATX headings (h1–h6), setext headings, fenced code blocks (```),
 *           indented code blocks, block quotes, ordered / unordered lists,
 *           task-list checkboxes (- [x] / - [ ]), horizontal rules, tables
 *           (GFM pipe-table syntax with alignment), paragraphs, blank-line
 *           separators, HTML pass-through blocks.
 *
 *   Inline: Bold (**…** / __…__), italic (*…* / _…_), bold+italic,
 *           inline code (`…`), strikethrough (~~…~~), links ([text](url)),
 *           reference links ([text][ref] + [ref]: url), images (![alt](url)),
 *           auto-links (<url>), line breaks (trailing two spaces or \).
 *
 * PUBLIC API
 *
 *   instance.render(markdown)   — re-render from a Markdown string
 *   instance.setSource(url)     — fetch a new URL and re-render
 *   instance.getHtml()          — return the last rendered HTML string
 *   instance.destroy()          — restore original content, unbind events
 *
 * USAGE
 *
 *   <!-- Render from inline text content -->
 *   <div data-plugin-markdown># Hello World</div>
 *
 *   <!-- Fetch and render from a URL -->
 *   <div data-plugin-markdown data-plugin-markdown-src="/docs/readme.md"></div>
 *
 *   <!-- Manual init with options -->
 *   <div id="md-target"># Hello</div>
 *   <script>
 *     $('#md-target').themestrapPluginMarkdown({ breaks: true, sanitize: true });
 *   </script>
 *
 * Auto-init wiring for themestrap.init.js:
 *
 *   if ($.isFunction($.fn['themestrapPluginMarkdown']) && $('[data-plugin-markdown]').length) {
 *       themestrap.fn.intObsInit('[data-plugin-markdown]:not(.manual)', 'themestrapPluginMarkdown');
 *   }
 */
// Markdown
(((themestrap = {}, $) => {

    const instanceName = '__pluginMarkdown';

    // Lazy CSS injection 
    const STYLE_ID = 'ts-markdown-styles';
    const CSS_TEXT = `
/* Themestrap — PluginMarkdown */
.ts-markdown {
    --ts-md-font:         inherit;
    --ts-md-code-font:    'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
    --ts-md-code-bg:      rgba(0,0,0,.06);
    --ts-md-border:       rgba(0,0,0,.15);
    --ts-md-quote-border: #0a1929;
    --ts-md-link:         #2ab8c8;
    --ts-md-link-hover:   #e8672a;
    --ts-md-table-stripe: rgba(0,0,0,.03);
    --ts-md-hr:           rgba(0,0,0,.15);
    --ts-md-heading-mt:   1.5em;
    line-height: 1.7;
    font-family: var(--ts-md-font);
    word-break: break-word;
}

/* Headings */
.ts-markdown h1, .ts-markdown h2, .ts-markdown h3,
.ts-markdown h4, .ts-markdown h5, .ts-markdown h6 {
    margin-top: var(--ts-md-heading-mt);
    margin-bottom: .4em;
    font-weight: 700;
    line-height: 1.25;
}
.ts-markdown h1 { font-size: 2em;    border-bottom: 2px solid var(--ts-md-border); padding-bottom: .25em; }
.ts-markdown h2 { font-size: 1.5em;  border-bottom: 1px solid var(--ts-md-border); padding-bottom: .2em;  }
.ts-markdown h3 { font-size: 1.25em; }
.ts-markdown h4 { font-size: 1.1em;  }
.ts-markdown h5 { font-size: 1em;    }
.ts-markdown h6 { font-size: .875em; color: #666;  }

/* Paragraphs & text */
.ts-markdown p { margin: 0 0 1em; }
.ts-markdown > *:first-child { margin-top: 0; }
.ts-markdown > *:last-child  { margin-bottom: 0; }

/* Links */
.ts-markdown a          { color: var(--ts-md-link); text-decoration: underline; }
.ts-markdown a:hover    { color: var(--ts-md-link-hover); }

/* Code */
.ts-markdown code {
    font-family: var(--ts-md-code-font);
    font-size: .875em;
    background: var(--ts-md-code-bg);
    border-radius: 3px;
    padding: .15em .4em;
}
.ts-markdown pre {
    background: var(--ts-md-code-bg);
    border: 1px solid var(--ts-md-border);
    border-radius: 6px;
    padding: 1em 1.2em;
    overflow-x: auto;
    margin: 0 0 1em;
}
.ts-markdown pre code {
    background: none;
    padding: 0;
    font-size: .875em;
    white-space: pre;
}

/* Block quotes */
.ts-markdown blockquote {
    margin: 0 0 1em;
    padding: .5em 1em;
    border-left: 4px solid var(--ts-md-quote-border);
    color: #555;
    background: rgba(0,0,0,.03);
    border-radius: 0 4px 4px 0;
}
.ts-markdown blockquote > p:last-child { margin-bottom: 0; }

/* Lists */
.ts-markdown ul,
.ts-markdown ol {
    margin: 0 0 1em;
    padding-left: 1.8em;
}
.ts-markdown li { margin-bottom: .25em; }
.ts-markdown li > ul,
.ts-markdown li > ol { margin-top: .25em; margin-bottom: 0; }

/* Task lists */
.ts-markdown .ts-md-task-list { list-style: none; padding-left: .5em; }
.ts-markdown .ts-md-task-list li { display: flex; align-items: flex-start; gap: .5em; }
.ts-markdown .ts-md-task-list input[type="checkbox"] { margin-top: .3em; flex-shrink: 0; cursor: default; }

/* Tables */
.ts-markdown table {
    border-collapse: collapse;
    width: 100%;
    margin: 0 0 1em;
    font-size: .9em;
}
.ts-markdown th,
.ts-markdown td {
    border: 1px solid var(--ts-md-border);
    padding: .5em .8em;
    text-align: left;
}
.ts-markdown th { background: rgba(0,0,0,.06); font-weight: 700; }
.ts-markdown tbody tr:nth-child(even) td { background: var(--ts-md-table-stripe); }

/* Horizontal rule */
.ts-markdown hr {
    border: none;
    border-top: 2px solid var(--ts-md-hr);
    margin: 1.5em 0;
}

/* Images */
.ts-markdown img { max-width: 100%; height: auto; }

/* Loading / error states */
.ts-markdown-loading { opacity: .5; font-style: italic; }
.ts-markdown-error   { color: #c0392b; font-style: italic; font-size: .9em; }
`;

    function injectStyles() {
        if (document.getElementById(STYLE_ID)) return;
        const style = document.createElement('style');
        style.id    = STYLE_ID;
        style.textContent = CSS_TEXT;
        (document.head || document.documentElement).appendChild(style);
    }

    // GFM Tokenizer / Renderer 
    // A self-contained, dependency-free GFM parser.
    // Covers the full feature set listed above.
    const GFM = (() => {

        function escHtml(str) {
            return str
                .replace(/&/g, '&amp;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;')
                .replace(/"/g, '&quot;')
                .replace(/'/g, '&#39;');
        }

        // Refuses javascript:, vbscript: and non-image data: URLs. Control characters and spaces are
        // removed first because browsers ignore them inside a scheme ("java\tscript:").
        function safeUrl(url) {
            const probe = String(url).replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
            if (/^(javascript|vbscript|livescript):/.test(probe)) return '#';
            if (/^data:/.test(probe) && !/^data:image\/(png|jpe?g|gif|webp);/.test(probe)) return '#';
            return url;
        }

        function renderInline(text, opts, refs) {
            let out = text;

            // Auto-links  <url>
            out = out.replace(/<(https?:\/\/[^\s>]+)>/g, (_, url) =>
                `<a href="${escHtml(safeUrl(url))}">${escHtml(url)}</a>`
            );

            // Inline code  `code`  (protect from further inline processing)
            const codePlaceholders = [];
            out = out.replace(/`([^`]+)`/g, (_, code) => {
                const ph = `\x02CODE${codePlaceholders.length}\x03`;
                codePlaceholders.push(`<code>${escHtml(code)}</code>`);
                return ph;
            });

            // Images  ![alt](url "title")
            out = out.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_, alt, rest) => {
                const [url, ...titleParts] = rest.trim().split(/\s+/);
                const title = titleParts.join(' ').replace(/^["']|["']$/g, '');
                return `<img src="${escHtml(safeUrl(url))}" alt="${escHtml(alt)}"${title ? ` title="${escHtml(title)}"` : ''}>`;
            });

            // Links  [text](url "title")
            out = out.replace(/\[([^\]]+)\]\(([^)]*)\)/g, (_, label, rest) => {
                const [url, ...titleParts] = rest.trim().split(/\s+/);
                const title = titleParts.join(' ').replace(/^["']|["']$/g, '');
                return `<a href="${escHtml(safeUrl(url))}"${title ? ` title="${escHtml(title)}"` : ''}>${renderInline(label, opts, refs)}</a>`;
            });

            // Reference links  [text][ref]  or  [text][]
            out = out.replace(/\[([^\]]+)\]\[([^\]]*)\]/g, (_, label, ref) => {
                const key = (ref || label).toLowerCase();
                if (refs[key]) {
                    const { href, title } = refs[key];
                    return `<a href="${escHtml(safeUrl(href))}"${title ? ` title="${escHtml(title)}"` : ''}>${renderInline(label, opts, refs)}</a>`;
                }
                return escHtml(`[${label}][${ref}]`);
            });

            // Shortcut reference links  [ref]
            out = out.replace(/\[([^\]]+)\]/g, (match, ref) => {
                const key = ref.toLowerCase();
                if (refs[key]) {
                    const { href, title } = refs[key];
                    return `<a href="${escHtml(safeUrl(href))}"${title ? ` title="${escHtml(title)}"` : ''}>${renderInline(ref, opts, refs)}</a>`;
                }
                return match; // leave unchanged — might be task-list bracket
            });

            // Bold + italic  ***…***  or  ___…___
            out = out.replace(/(\*{3}|_{3})(.+?)\1/g, (_, __, inner) =>
                `<strong><em>${renderInline(inner, opts, refs)}</em></strong>`
            );

            // Bold  **…**  or  __…__
            out = out.replace(/(\*{2}|_{2})(.+?)\1/g, (_, __, inner) =>
                `<strong>${renderInline(inner, opts, refs)}</strong>`
            );

            // Italic  *…*  or  _…_
            out = out.replace(/([*_])(.+?)\1/g, (_, __, inner) =>
                `<em>${renderInline(inner, opts, refs)}</em>`
            );

            // Strikethrough  ~~…~~
            out = out.replace(/~~(.+?)~~/g, (_, inner) =>
                `<del>${renderInline(inner, opts, refs)}</del>`
            );

            // Line breaks — trailing two spaces or backslash
            out = out
                .replace(/ {2,}\n/g, '<br>\n')
                .replace(/\\\n/g, '<br>\n');

            if (opts.breaks) {
                // GitHub-style soft line breaks
                out = out.replace(/\n/g, '<br>\n');
            }

            // Restore code placeholders
            codePlaceholders.forEach((html, i) => {
                out = out.replace(`\x02CODE${i}\x03`, html);
            });

            return out;
        }

        // Block tokeniser 
        // Returns { tokens, refs } where tokens is an array of block token objs.
        function tokenize(src) {
            const refs   = {}; // link reference definitions
            const tokens = [];

            // Normalize line endings; add trailing newline for easier regex
            let lines = src.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
            // Strip link reference definitions  [ref]: url "title"
            lines = lines.replace(
                /^\[([^\]]+)\]:\s+(\S+)(?:\s+"([^"]*)")?\s*$/gm,
                (_, ref, href, title) => {
                    refs[ref.toLowerCase()] = { href, title: title || '' };
                    return '';
                }
            );

            const lineArr = lines.split('\n');
            let i = 0;

            while (i < lineArr.length) {
                const line = lineArr[i];

                if (/^\s*$/.test(line)) {
                    i++;
                    continue;
                }

                const fenceMatch = line.match(/^(`{3,}|~{3,})\s*(\S*)/);
                if (fenceMatch) {
                    const fence = fenceMatch[1];
                    const lang  = fenceMatch[2] || '';
                    const body  = [];
                    i++;
                    while (i < lineArr.length) {
                        if (lineArr[i].trimStart().startsWith(fence.slice(0, 3)) &&
                            lineArr[i].trim().replace(new RegExp(`^${fence[0]}+`), '') === '') {
                            i++;
                            break;
                        }
                        body.push(lineArr[i]);
                        i++;
                    }
                    tokens.push({ type: 'code', lang, text: body.join('\n') });
                    continue;
                }

                if (/^( {4}|\t)/.test(line)) {
                    const body = [];
                    while (i < lineArr.length && /^( {4}|\t)/.test(lineArr[i])) {
                        body.push(lineArr[i].replace(/^( {4}|\t)/, ''));
                        i++;
                    }
                    tokens.push({ type: 'code', lang: '', text: body.join('\n') });
                    continue;
                }

                const atxMatch = line.match(/^(#{1,6})\s+(.+?)(?:\s+#+)?\s*$/);
                if (atxMatch) {
                    tokens.push({ type: 'heading', depth: atxMatch[1].length, text: atxMatch[2] });
                    i++;
                    continue;
                }

                if (i + 1 < lineArr.length) {
                    const next = lineArr[i + 1];
                    if (/^=+\s*$/.test(next)) {
                        tokens.push({ type: 'heading', depth: 1, text: line });
                        i += 2;
                        continue;
                    }
                    if (/^-+\s*$/.test(next) && !/^\s*[-*_](\s*[-*_]){2,}\s*$/.test(next)) {
                        tokens.push({ type: 'heading', depth: 2, text: line });
                        i += 2;
                        continue;
                    }
                }

                if (/^\s*([-*_])\s*\1\s*\1(\s*\1)*\s*$/.test(line)) {
                    tokens.push({ type: 'hr' });
                    i++;
                    continue;
                }

                if (/^>/.test(line)) {
                    const body = [];
                    while (i < lineArr.length && /^>/.test(lineArr[i])) {
                        body.push(lineArr[i].replace(/^>\s?/, ''));
                        i++;
                    }
                    tokens.push({ type: 'blockquote', src: body.join('\n') });
                    continue;
                }

                if (i + 1 < lineArr.length && /^\|/.test(line) && /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?$/.test(lineArr[i + 1])) {
                    const headerCells = parseTableRow(line);
                    const alignRow    = parseTableRow(lineArr[i + 1]);
                    const aligns      = alignRow.map(cell => {
                        const c = cell.trim();
                        if (c.startsWith(':') && c.endsWith(':')) return 'center';
                        if (c.endsWith(':')) return 'right';
                        if (c.startsWith(':')) return 'left';
                        return '';
                    });
                    i += 2;
                    const rows = [];
                    while (i < lineArr.length && /\|/.test(lineArr[i])) {
                        rows.push(parseTableRow(lineArr[i]));
                        i++;
                    }
                    tokens.push({ type: 'table', headers: headerCells, aligns, rows });
                    continue;
                }

                const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)/);
                const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)/);
                if (ulMatch || olMatch) {
                    const ordered = !!olMatch;
                    const indent  = (ulMatch || olMatch)[1].length;
                    const items   = [];

                    while (i < lineArr.length) {
                        const l    = lineArr[i];
                        const ulM  = l.match(/^(\s*)([-*+])\s+(.*)/);
                        const olM  = l.match(/^(\s*)(\d+)\.\s+(.*)/);
                        const indM = l.match(/^(\s+)(.*)/);

                        if ((ulM || olM) && (ulM || olM)[1].length === indent) {
                            const itemText = (ulM || olM)[3];
                            items.push({ text: itemText, children: [], loose: false });
                            i++;
                        } else if (indM && indM[1].length > indent) {
                            // Continuation / nested content
                            if (items.length) {
                                items[items.length - 1].text += '\n' + indM[2];
                            }
                            i++;
                        } else if (/^\s*$/.test(l) && items.length) {
                            items[items.length - 1].loose = true;
                            i++;
                        } else {
                            break;
                        }
                    }

                    tokens.push({ type: 'list', ordered, items });
                    continue;
                }

                if (/^<[a-zA-Z]/.test(line)) {
                    const body = [];
                    while (i < lineArr.length && !/^\s*$/.test(lineArr[i])) {
                        body.push(lineArr[i]);
                        i++;
                    }
                    tokens.push({ type: 'html', text: body.join('\n') });
                    continue;
                }

                const paraLines = [];
                while (i < lineArr.length) {
                    const l = lineArr[i];
                    if (/^\s*$/.test(l))          break;
                    if (/^#{1,6}\s/.test(l))       break;
                    if (/^(`{3,}|~{3,})/.test(l)) break;
                    if (/^\s*([-*_])\s*\1\s*\1/.test(l)) break;
                    if (/^>/.test(l))              break;
                    if (/^(\s*)([-*+]|\d+\.)\s/.test(l) && paraLines.length > 0) break;
                    paraLines.push(l);
                    i++;
                }
                if (paraLines.length) {
                    tokens.push({ type: 'paragraph', text: paraLines.join('\n') });
                }
            }

            return { tokens, refs };
        }

        function parseTableRow(row) {
            return row
                .replace(/^\||\|$/g, '')
                .split('|')
                .map(c => c.trim());
        }

        function renderTokens(tokens, opts, refs) {
            let html = '';

            for (const tok of tokens) {
                switch (tok.type) {

                    case 'heading': {
                        const tag = `h${tok.depth}`;
                        const id  = tok.text.toLowerCase().replace(/[^\w]+/g, '-');
                        html += `<${tag} id="${id}">${renderInline(tok.text, opts, refs)}</${tag}>\n`;
                        break;
                    }

                    case 'hr':
                        html += '<hr>\n';
                        break;

                    case 'code': {
                        const langClass = tok.lang ? ` class="language-${escHtml(tok.lang)}"` : '';
                        html += `<pre><code${langClass}>${escHtml(tok.text)}</code></pre>\n`;
                        break;
                    }

                    case 'blockquote': {
                        const inner = renderTokens(tokenize(tok.src).tokens, opts, refs);
                        html += `<blockquote>\n${inner}</blockquote>\n`;
                        break;
                    }

                    case 'table': {
                        let t = '<table>\n<thead>\n<tr>\n';
                        tok.headers.forEach((h, ci) => {
                            const align = tok.aligns[ci] ? ` style="text-align:${tok.aligns[ci]}"` : '';
                            t += `<th${align}>${renderInline(h, opts, refs)}</th>\n`;
                        });
                        t += '</tr>\n</thead>\n<tbody>\n';
                        tok.rows.forEach(row => {
                            t += '<tr>\n';
                            row.forEach((cell, ci) => {
                                const align = tok.aligns[ci] ? ` style="text-align:${tok.aligns[ci]}"` : '';
                                t += `<td${align}>${renderInline(cell, opts, refs)}</td>\n`;
                            });
                            t += '</tr>\n';
                        });
                        t += '</tbody>\n</table>\n';
                        html += t;
                        break;
                    }

                    case 'list': {
                        const tag   = tok.ordered ? 'ol' : 'ul';
                        let isTask  = false;
                        let listHtml = '';

                        tok.items.forEach(item => {
                            // Task-list detection  [ ] / [x]
                            const taskMatch = item.text.match(/^\[([ xX])\]\s*([\s\S]*)/);
                            if (taskMatch) {
                                isTask      = true;
                                const done  = taskMatch[1].toLowerCase() === 'x';
                                const rest  = taskMatch[2];
                                listHtml   += `<li><input type="checkbox"${done ? ' checked' : ''} disabled> ${renderInline(rest, opts, refs)}</li>\n`;
                            } else {
                                const content = tok.loose
                                    ? `<p>${renderInline(item.text, opts, refs)}</p>`
                                    : renderInline(item.text, opts, refs);
                                listHtml   += `<li>${content}</li>\n`;
                            }
                        });

                        const cls = isTask ? ` class="ts-md-task-list"` : '';
                        html += `<${tag}${cls}>\n${listHtml}</${tag}>\n`;
                        break;
                    }

                    case 'html':
                        // Pass raw HTML through unchanged (sanitize option strips this)
                        if (!opts.sanitize) {
                            html += tok.text + '\n';
                        }
                        break;

                    case 'paragraph': {
                        // Soft-break handling: single newlines become spaces unless opts.breaks
                        const text = opts.breaks
                            ? tok.text
                            : tok.text.replace(/\n(?!\n)/g, ' ');
                        html += `<p>${renderInline(text, opts, refs)}</p>\n`;
                        break;
                    }
                }
            }

            return html;
        }

        // Minimal strip: removes <script> and on* attributes.
        // Defence in depth for raw HTML pass-through: drops active elements and event handlers,
        // and neutralizes script-bearing URL attributes.
        function sanitize(html) {
            return html
                .replace(/<(script|iframe|object|embed|style|link|meta|base|frame|frameset|applet)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
                .replace(/<\/?(script|iframe|object|embed|style|link|meta|base|frame|frameset|applet)\b[^>]*>/gi, '')
                .replace(/\s+on\w+\s*=\s*"[^"]*"/gi, '')
                .replace(/\s+on\w+\s*=\s*'[^']*'/gi, '')
                .replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '')
                .replace(/\s(href|src|action|formaction|xlink:href)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi, (m, attr, whole, a, b, c) => {
                    const v = a !== undefined ? a : (b !== undefined ? b : c);
                    return safeUrl(v.replace(/&#(x?[0-9a-f]+);?/gi, (x, n) => String.fromCharCode(n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : parseInt(n, 10)))) === '#' ? ` ${attr}="#"` : m;
                });
        }

        function parse(src, opts) {
            const { tokens, refs } = tokenize(src);
            let   html             = renderTokens(tokens, opts, refs);
            if (opts.sanitize) html = sanitize(html);
            return html;
        }

        return { parse };
    })();

    class PluginMarkdown {

        constructor($el, opts) {
            return this.initialize($el, opts);
        }

        initialize($el, opts) {
            if ($el.data(instanceName)) {
                return this;
            }
 
            this.$el = $el;
 
            // Snapshot original content before build mutates the DOM
            this._originalHTML       = $el.html();
            this._renderedHtml       = '';
            this._highlightInstances = [];
 
            this
                .setData()
                .setOptions(opts)
                .build();
 
            return this;
        }

        setData() {
            this.$el.data(instanceName, this);
            return this;
        }

        setOptions(opts) {
            const pluginOpts = themestrap.fn.getOptions(this.$el.data('plugin-options')) || {};

            this.options = $.extend(true, {}, PluginMarkdown.defaults, pluginOpts, opts, {
                wrapper: this.$el
            });
            
            return this;
        }

        build() {
            injectStyles();

            const self    = this;
            const $el     = self.$el;
            const options = self.options;

            $el.addClass('ts-markdown');

            // CSS custom property overrides
            if (options.linkColor)    $el.css('--ts-md-link',   options.linkColor);
            if (options.codeFont)     $el.css('--ts-md-code-font', options.codeFont);

            const src = $el.data('plugin-markdown-src') || options.src || '';

            if (src) {
                self.setSource(src);
            } else {
                // Render from existing text content, decoded from HTML entities
                const raw = $el.text();
                self.render(raw);
            }

            return this;
        }

        /**
         * Parse and render a Markdown string into the host element.
         * @param {string} markdown
         * @returns {PluginMarkdown}
         */
        render(markdown) {
            const html = GFM.parse(markdown, this.options);
            this._renderedHtml = html;
            this.$el.html(html);
            this._highlightBlocks();
            this.$el.trigger('rendered.ts.markdown', [html]);
            return this;
        }

        /**
         * Fetch a Markdown file from a URL and render it.
         * @param {string} url
         * @returns {PluginMarkdown}
         */
        setSource(url) {
            const self = this;
            const $el  = self.$el;

            $el.addClass('ts-markdown-loading');
            $el.html('');

            $.ajax({
                url,
                dataType: 'text',
                success(md) {
                    $el.removeClass('ts-markdown-loading');
                    self.render(md);
                },
                error(xhr, status, err) {
                    $el.removeClass('ts-markdown-loading');
                    $el.html(`<p class="ts-markdown-error">Failed to load <code>${url}</code>: ${err || status}</p>`);
                    $el.trigger('error.ts.markdown', [url, err]);
                }
            });

            return this;
        }

        /**
         * Return the last rendered HTML string.
         * @returns {string}
         */
        getHtml() {
            return this._renderedHtml;
        }
 
        /**
         * Pass every fenced-code <pre> emitted by the GFM renderer to
         * PluginHighlight (if loaded).  Called automatically by render().
         *
         * The GFM renderer writes:
         *   <pre><code class="language-js">…</code></pre>
         *
         * PluginHighlight._normalizePre() walks from <code> up to the nearest
         * <pre>, so targeting the <code> elements directly is safe and avoids
         * any ambiguity with plain <pre> blocks that have no language class.
         *
         * Each highlight instance is stored in this._highlightInstances so that
         * destroy() can tear them down cleanly.
         *
         * @returns {PluginMarkdown}
         */
        _highlightBlocks() {
            if (!this.options.highlight) return this;
            if (!$.isFunction($.fn.themestrapPluginHighlight)) return this;
 
            // Tear down any highlight instances from the previous render pass
            // before creating new ones (re-render scenario).
            this._destroyHighlightInstances();
 
            const self   = this;
            const hlOpts = $.extend(
                { lineNumbers: false, showCopy: true },
                self.options.highlightOptions || {}
            );
 
            // Find every <code class="language-*"> the GFM renderer produced.
            // Blocks without a language class are plain indented-code blocks —
            // leave them unstyled so they don't get unwanted line numbers.
            self.$el.find('code[class*="language-"]').each(function () {
                const $code = $(this);
                const $pre  = $code.closest('pre');
                if (!$pre.length) return; // orphaned <code> — skip
 
                // Extract the bare language string from the class, e.g.
                // "language-javascript" → "javascript".
                const langClass = ($code.attr('class') || '')
                    .split(/\s+/)
                    .find(c => c.startsWith('language-'));
 
                const lang = langClass ? langClass.replace('language-', '') : '';
 
                // PluginHighlight.setOptions() resolves the language by reading
                // the data-plugin-highlight attribute directly from the <pre>.
                // It does NOT read opts.language from the JS options bag —
                // that path is overridden by the attribute resolution earlier
                // in setOptions().  Setting the attribute here is the only
                // reliable way to pass the language to the plugin.
                $pre.attr('data-plugin-highlight', lang);
 
                // Forward shared highlightOptions.  Remove any language key the
                // caller may have set — the attribute above is authoritative.
                const blockOpts = $.extend({}, hlOpts);
                delete blockOpts.language;
 
                // Call on the <pre> directly — no normalization walk needed.
                $pre.themestrapPluginHighlight(blockOpts);
 
                // Store the instance for clean teardown on destroy().
                const hlInst = $pre.data('__highlight');
                if (hlInst) self._highlightInstances.push(hlInst);
            });
 
            return this;
        }
 
        /**
         * Destroy all PluginHighlight instances created by _highlightBlocks().
         * @returns {PluginMarkdown}
         */
        _destroyHighlightInstances() {
            this._highlightInstances.forEach(inst => {
                try { inst.destroy(); } catch (_) { /* already torn down */ }
            });
            this._highlightInstances = [];
            return this;
        }
 
        destroy() {
            this._destroyHighlightInstances();
 
            this.$el
                .html(this._originalHTML)
                .removeClass('ts-markdown ts-markdown-loading ts-markdown-error')
                .css({
                    '--ts-md-link':      '',
                    '--ts-md-code-font': ''
                })
                .removeData(instanceName);
 
            return this;
        }
    }

    PluginMarkdown.defaults = {
        /**
         * Source URL to fetch Markdown from. Also readable from
         * data-plugin-markdown-src on the element.
         */
        src: '',

        /**
         * Convert single trailing newlines inside paragraphs to <br>.
         * Mimics GitHub's "new lines as line breaks" soft-break behaviour.
         */
        breaks: false,

        /**
         * Strip <script> tags and on* event attribute from output HTML.
         * Enabled by default for untrusted content.
         */
        sanitize: true,

        /**
         * Render GFM task-list syntax  - [ ] / - [x].
         * Checkboxes are rendered disabled (read-only).
         */
        taskLists: true,

        /**
         * Override the link colour via CSS custom property.
         * Accepts any CSS colour string; maps to --ts-md-link.
         */
        linkColor: '',

        /**
         * Override the code-block font stack via CSS custom property.
         * Maps to --ts-md-code-font.
         */
        codeFont: '',
 
        /**
         * Pass rendered fenced code blocks to PluginHighlight automatically.
         * Requires $.fn.themestrapPluginHighlight to be present on the page.
         * Set to false to disable syntax highlighting entirely.
         */
        highlight: true,
 
        /**
         * Options forwarded to every PluginHighlight instance created for
         * code blocks inside this Markdown element.  These mirror the
         * PluginHighlight.defaults keys — see themestrap.plugin.highlight.js.
         *
         * Defaults applied here:
         *   lineNumbers: false   (line numbers off by default in prose contexts)
         *   showCopy:    true    (copy button retained)
         *
         * Example:
         *   highlightOptions: { lineNumbers: true, showCopy: false }
         */
        highlightOptions: {}
    };

    $.extend(themestrap, { PluginMarkdown });

    $.fn.themestrapPluginMarkdown = function(opts) {
        return this.map(function() {
            const $this = $(this);
            if ($this.data(instanceName)) {
                return $this.data(instanceName);
            } else {
                return new PluginMarkdown($this, opts);
            }
        });
    };

})).apply(this, [window.themestrap, jQuery]);