Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
portableapps
/
assets
/
admin
/
themestrap
:
themestrap.plugin.markdown.js
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
/** * 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 (), * 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, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // 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  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]);