init

alphons <alphons@heijden.com> 21 Mar 2026, 10:00
722597a89c670057d866552fdc620a7cb6c99fee
0 files changed
diff --git a/src/md4.js b/src/md4.js
new file mode 100644
index 0000000..397c082
--- /dev/null
+++ b/src/md4.js
@@ -0,0 +1,1123 @@
+/**
+ * md v4.0.1 - a markdown streaming parser
+ * Copyright (c) 2025-2026, Alphons van der Heijden
+ * https://github.com/alphons/MarkdownStreamer
+ */
+
+'use strict';
+
+// ─── Module-level constants ────────────────────────────────────────────────────
+const ENTITY_MAP = {
+ '&amp;':'&','&lt;':'<','&gt;':'>','&quot;':'"',"&apos;":"'",'&nbsp;':'\u00a0',
+ '&copy;':'©','&reg;':'®','&trade;':'™','&euro;':'€','&pound;':'£','&yen;':'¥',
+ '&mdash;':'—','&ndash;':'–','&laquo;':'«','&raquo;':'»','&hellip;':'…',
+ '&rarr;':'→','&larr;':'←','&uarr;':'↑','&darr;':'↓',
+ '&times;':'×','&divide;':'÷','&plusmn;':'±','&deg;':'°',
+ '&frac12;':'½','&frac14;':'¼','&frac34;':'¾','&hearts;':'♥','&spades;':'♠',
+};
+const BLOCK_TAGS = new Set(['details','summary','figure','figcaption','aside','section','article','div','table','thead','tbody','tr','td','th','ul','ol','li','dl','dt','dd','form','nav','header','footer','main','blockquote','pre','hr','h1','h2','h3','h4','h5','h6']);
+
+// ─── DomStack ─────────────────────────────────────────────────────────────────
+class DomStack {
+ constructor(root) { this.current = root; this.bottomStack = root; }
+
+ push(nodeName) {
+ const el = document.createElement(nodeName);
+ this.current.appendChild(el);
+ this.current = el;
+ return el;
+ }
+
+ pop() {
+ if (this.current !== this.bottomStack && this.current.parentNode)
+ this.current = this.current.parentNode;
+ return this.current;
+ }
+
+ popTo(el) { this.current = el; }
+ toRoot() { this.current = this.bottomStack; }
+ currentTag() { return this.current.nodeName; }
+
+ find(tag) {
+ const t = tag.toUpperCase();
+ let el = this.current;
+ while (el) {
+ if (el.nodeName === t) return el;
+ if (el === this.bottomStack) break;
+ el = el.parentNode;
+ }
+ return null;
+ }
+
+ depth() {
+ let d = 1, el = this.current;
+ while (el !== this.bottomStack && el.parentNode) { d++; el = el.parentNode; }
+ return d;
+ }
+
+ replaceAt(oldEl, newEl) { if (this.current === oldEl) this.current = newEl; }
+}
+
+// ─── MarkdownStreamer ──────────────────────────────────────────────────────────
+class MarkdownStreamer {
+ constructor(rootEl) {
+ rootEl.innerHTML = '';
+ this.root = rootEl;
+ this.dom = new DomStack(rootEl);
+
+ this.linePos = 0; this.lineIndent = 0; this.blockDecided = false;
+ this.pending = ''; this.lastBlockEl = null; this.lineStart = true;
+ this.listStack = [];
+ this.inlinePending = ''; this.textNode = null;
+ this.escapeNext = false; this.entityBuf = null;
+ this.autolinkBuf = null;
+ this.bareUrlBuf = null; this.bareUrlOpen = false; this.prevCharWs = true;
+ this.linkState = null; this.linkBuf = ''; this.urlBuf = ''; this.linkIsImage = false;
+ this.refDefs = {};
+ this.inCodeFence = false; this.inIndentCode = false; this.pendingIndentNL = 0;
+ this.fenceChar = '`'; this.fencePrefix = ''; this.closingFenceBuf = null;
+ this.inTable = false; this.tableHeadDone = false; this.tableColAlign = [];
+ this.tableColIndex = 0; this.inCell = false; this.tablePipePending = false;
+ this._resetSetext(); this._resetHr();
+ this.trailingSpaces = 0;
+ this.taskCheckBuf = null; this.taskCheckDone = false;
+ this.footnoteDefs = {}; this.footnoteOrder = []; this.inFootnoteDef = false; this.footnoteDefId = '';
+ this.abbrMap = {};
+ this.defPending = null;
+ this.inRawHtml = false; this.rawHtmlBuf = ''; this.rawHtmlTag = null;
+
+ this.sepWatch = false; this.sepFailed = false; this.sepRowEl = null; this.sepBuf = '';
+ }
+
+ // ── Private helpers ────────────────────────────────────────────────────────
+ _bd() { this.blockDecided = true; this.pending = ''; }
+ _pop(el) { this.dom.popTo(el); this.dom.pop(); }
+ _popMarkers() { while (this.dom.current._mdMarker) this.dom.pop(); }
+ _resetLinkUrl() { this.linkState = null; this.urlBuf = ''; this.textNode = null; }
+ _resetSetext() { this.setextWatch = false; this.setextBuf = ''; this.setextChar = ''; this.setextFailed = false; }
+ _resetHr() { this.hrWatch = false; this.hrChar = ''; this.hrCount = 0; this.hrFailed = false; }
+ _doneTaskCheck() { this.taskCheckDone = true; this.taskCheckBuf = null; }
+ _bqLevel(p) {
+ let level = 0, i = 0;
+ while (i < p.length && p[i] === '>') { level++; i++; if (i < p.length && p[i] === ' ') i++; }
+ return { level, i };
+ }
+
+ // ── Public processChar ─────────────────────────────────────────────────────
+ processChar(ch) {
+ if (ch === '\n') { this.onNewline(); return; }
+ if (this.inCodeFence) {
+ if (this.fencePrefix === null) { this.feedCodeFenceLine(ch); return; }
+ this.fencePrefix += ch; return;
+ }
+ if (this.inRawHtml) { this.rawHtmlBuf += ch; return; }
+
+ this.linePos++;
+ if (this.lineStart) this.lineStart = false;
+
+ if (!this.blockDecided) {
+ if (ch === ' ' && this.linePos === this.lineIndent + 1) {
+ this.lineIndent++;
+ if (this.lineIndent === 4 && this.dom.currentTag() !== 'LI') {
+ if (!this.inIndentCode) {
+ this.closeBlock();
+ const pre = this.dom.push('pre');
+ const code = document.createElement('code');
+ pre.appendChild(code);
+ this.textNode = document.createTextNode(''); code.appendChild(this.textNode);
+ this.inIndentCode = true; this.lastBlockEl = pre;
+ }
+ this._bd(); this.lineIndent = 0;
+ }
+ return;
+ }
+ this.inIndentCode = false; this.pendingIndentNL = 0;
+ this.decideBlock(ch); return;
+ }
+
+ if (ch === ' ') this.trailingSpaces++;
+ else this.trailingSpaces = 0;
+ this.onContentChar(ch);
+ }
+
+ // ── Newline ────────────────────────────────────────────────────────────────
+ onNewline() {
+ if (this.defPending) { this.flushDefPending(); this.resetLine(); return; }
+
+ if (this.sepWatch && this.inTable) {
+ this.sepWatch = false;
+ if (!this.sepFailed && this.sepRowEl) {
+ this.applyTableSep('|' + this.sepBuf);
+ const tr = this.sepRowEl;
+ this._pop(tr); tr.remove();
+ const thead = this.dom.find('THEAD');
+ if (thead) this._pop(thead);
+ this.tableHeadDone = true;
+ const tbody = document.createElement('tbody');
+ this.dom.find('TABLE').appendChild(tbody); this.dom.current = tbody;
+ this.textNode = null; this.inCell = false; this.tablePipePending = false; this.tableColIndex = 0;
+ this.sepRowEl = null; this.resetLine(); return;
+ }
+ this.sepRowEl = null;
+ }
+
+ if (this.inCodeFence) { this.onCodeFenceNewline(); return; }
+ if (this.inIndentCode) {
+ if (!this.blockDecided) this.pendingIndentNL++;
+ else { if (this.textNode) this.textNode.data += '\n'.repeat(this.pendingIndentNL) + '\n'; this.pendingIndentNL = 0; }
+ this.resetLine(); return;
+ }
+ if (this.inRawHtml) {
+ this.rawHtmlBuf += '\n';
+ if (!this.rawHtmlTag) {
+ const m = this.rawHtmlBuf.match(/^<([a-zA-Z][a-zA-Z0-9-]*)/);
+ this.rawHtmlTag = m ? m[1].toLowerCase() : null;
+ }
+ if (this.rawHtmlTag && new RegExp('<\\/' + this.rawHtmlTag + '\\s*>$', 'i').test(this.rawHtmlBuf.trimEnd()))
+ this.flushRawHtml();
+ return;
+ }
+
+ if (this.setextWatch) {
+ if (!this.setextFailed && this.setextBuf.length >= 1) this.resolveSetext(this.setextChar === '=' ? 'h1' : 'h2');
+ else this.flushSetextAsFallback();
+ this._resetSetext();
+ this.resetLine(); return;
+ }
+ if (this.hrWatch) {
+ if (!this.hrFailed && this.hrCount >= 3) this.makeHr();
+ else this.flushHrAsFallback();
+ this._resetHr();
+ this.resetLine(); return;
+ }
+
+ if (!this.blockDecided && this.pending) {
+ const p = this.pending;
+ if (p[0] === '>') {
+ const { level } = this._bqLevel(p);
+ if (level > 0) { this.ensureBlockquote(level); this.openParagraph(); }
+ } else if ((p[0] === '`' || p[0] === '~') && p.length >= 3 && p.split('').every(c => c === p[0])) {
+ this.closeBlock(); this.inCodeFence = true; this.fenceChar = p[0];
+ this.fenceCount = p.length; this.fencePrefix = null; this.closingFenceBuf = null;
+ this.onCodeFenceNewline();
+ } else if (p[0] === '*' && /^\*{3,}$/.test(p)) {
+ this.makeHr();
+ } else if (p[0] === '-' && /^-{3,}$/.test(p)) {
+ if (this.lastBlockEl?.tagName === 'P') this.resolveSetext('h2');
+ else this.makeHr();
+ } else if (/^[_ ]+$/.test(p) && (p.match(/_/g)||[]).length >= 3) {
+ this.makeHr();
+ } else if (/^- (- ?)+$/.test(p.trimEnd()) && (p.match(/-/g)||[]).length >= 3) {
+ this.makeHr();
+ } else if (p[0] === '-' && /^- /.test(p)) {
+ this.openUlDecided(p.slice(2));
+ } else if (p[0] === '[') {
+ this.fallbackToParagraph();
+ }
+ this.pending = '';
+ }
+
+ if (!this.blockDecided) {
+ const tag = this.dom.currentTag();
+ if (tag === 'P') { this.dom.pop(); this.textNode = null; }
+ else if (tag === 'LI') { this.dom.toRoot(); this.listStack = []; this.textNode = null; }
+ else if (tag === 'DD') { this.dom.toRoot(); this.textNode = null; }
+ this.resetLine(); return;
+ }
+
+ if ((this.trailingSpaces >= 2 || this.escapeNext) && this.blockDecided) {
+ if (this.textNode) this.textNode.data = this.textNode.data.replace(/ +$/, '');
+ this.dom.current.appendChild(document.createElement('br'));
+ this.textNode = null;
+ }
+ this.escapeNext = false;
+
+ if (this.bareUrlOpen) {
+ const a = this.dom.find('A');
+ if (a && !a.href) { a.href = a.textContent.trim(); this._pop(a); }
+ this._resetBareUrl();
+ }
+
+ this.flushInlinePending();
+ if (this.linkState === 'expect_paren') {
+ const a = this.dom.find('A');
+ if (a && !a.href) { a.dataset.implicitRef = this.linkBuf.toLowerCase(); this._pop(a); }
+ this._resetLinkUrl();
+ } else if (this.linkState !== null) {
+ this.abortLinkElement(null);
+ }
+
+ if (this.atxLevel && this.textNode)
+ this.textNode.data = this.textNode.data.replace(/\s+#+\s*$/, '').replace(/\s+#+$/, '');
+ this.atxLevel = 0;
+ this.textNode = null;
+
+ this._popMarkers();
+ if (this.inFootnoteDef) { this.dom.toRoot(); this.inFootnoteDef = false; this.footnoteDefId = ''; }
+ this.resetLine();
+ }
+
+ resetLine() {
+ this.linePos = 0; this.lineIndent = 0; this.blockDecided = false;
+ this.pending = ''; this.inlinePending = ''; this.atxLevel = 0;
+ this.linkState = null; this.linkBuf = ''; this.urlBuf = ''; this.linkIsImage = false;
+ this.inCell = false; this.tablePipePending = false; this.trailingSpaces = 0;
+ this.lineStart = true; this.prevCharWs = true; this.bareUrlBuf = null;
+ this.escapeNext = false; this.entityBuf = null; this.autolinkBuf = null;
+ this.taskCheckBuf = null; this.taskCheckDone = false;
+ }
+
+ // ── Block decision ─────────────────────────────────────────────────────────
+ _blockDefault(ch) {
+ if (this.defPending) { this.defPending.value += ch; return; }
+ const tag = this.dom.currentTag();
+ if (tag === 'P' || tag === 'LI' || tag === 'DD') { this._bd(); this.feedPendingAsInline(); return; }
+ this.fallbackToParagraph();
+ }
+
+ decideBlock(ch) {
+ this.pending += ch;
+ const p = this.pending;
+
+ switch (p[0]) {
+ case '#':
+ if (ch === '#' && p.length <= 6) return;
+ if (ch === ' ' && p.length >= 2 && /^#{1,6}$/.test(p.slice(0,-1))) {
+ const level = p.length - 1;
+ this.closeBlock(); this.listStack = [];
+ const h = this.dom.push('h' + Math.min(level, 6)); this.lastBlockEl = h;
+ this._bd(); this.atxLevel = level; return;
+ }
+ if (ch !== '#') this.fallbackToParagraph();
+ return;
+
+ case '>': {
+ const { level, i } = this._bqLevel(p);
+ if (i === p.length) return;
+ this.ensureBlockquote(level); this.openParagraph(); this._bd();
+ for (const c of p.slice(i)) this.onInlineChar(c);
+ return;
+ }
+
+ case '`':
+ case '~': {
+ const fc = p[0];
+ if (p.length < 3) return;
+ if (p.split('').every(c => c === fc)) return;
+ const fenceCount = p.length - 1;
+ if (fenceCount >= 3) {
+ this.closeBlock(); this.inCodeFence = true; this.fenceChar = fc;
+ this.fenceCount = fenceCount; this.fencePrefix = ch; this.closingFenceBuf = null;
+ this._bd(); return;
+ }
+ this.fallbackToParagraph(); return;
+ }
+
+ case '|':
+ if (!this.inTable) { this.closeBlock(); this.openTable(); }
+ else this.startTableRow();
+ this._bd(); this.openTableCell(); return;
+
+ case '*': {
+ // Abbreviation definition *[Abbr]:
+ if (p[1] === '[') {
+ const ci = p.indexOf(']:');
+ if (ci > 2) { this.defPending = { type: 'abbr', key: p.slice(2, ci), value: '' }; this._bd(); return; }
+ const bracketIdx = p.indexOf(']', 2);
+ if (bracketIdx === -1) return;
+ if (p.length === bracketIdx + 1) return;
+ this.fallbackToParagraph(); return;
+ }
+ // Unordered list / thematic break
+ if (p.length === 1) return;
+ if (p.length === 2) {
+ if (p[1] === ' ') return this.openUlDecided('');
+ if (p[1] !== '*') return this.fallbackToParagraph();
+ return;
+ }
+ if (p.length === 3) { if (p === '***') return; this.fallbackToParagraph(); return; }
+ if (p.length === 4) {
+ if (p[3] === ' ' || p[3] === '*') return this.startHrWatch('*', p.split('*').length - 1, false);
+ this.fallbackToParagraph(); return;
+ }
+ this.fallbackToParagraph(); return;
+ }
+
+ case '-':
+ if (p.length === 1) return;
+ if (p.length === 2) {
+ if (p[1] === ' ') return;
+ if (this.lastBlockEl?.tagName === 'P') return this.startSetextWatch('-', p, p[1] !== '-');
+ return this.startHrWatch('-', 2, p[1] !== '-');
+ }
+ if (p.length === 3) {
+ if (p === '- -' || p === '- *') return;
+ if (p[1] === ' ' && p[2] !== '-' && p[2] !== ' ') return this.openUlDecided(p[2]);
+ if (p[1] === ' ' && p[2] === ' ') return;
+ return (this.lastBlockEl?.tagName === 'P')
+ ? this.startSetextWatch('-', p, false)
+ : this.startHrWatch('-', p.split('-').length - 1, false);
+ }
+ if (p.length === 4) {
+ if (p === '- - ') return;
+ if (p.startsWith('- -')) return this.startHrWatch('-', 2, false);
+ return this.openUlDecided(p.slice(2));
+ }
+ if (/^(- )+$/.test(p) || /^(- )+-?$/.test(p)) return;
+ if (/^- /.test(p)) return this.openUlDecided(p.slice(2));
+ return this.startHrWatch('-', (p.match(/-/g)||[]).length, false);
+
+ case '+':
+ if (p.length === 1) return;
+ if (p[1] === ' ') { this.openUlDecided(p.slice(2)); return; }
+ this.fallbackToParagraph(); return;
+
+ case '_':
+ if (/^[_ ]+$/.test(p)) return;
+ this.fallbackToParagraph(); return;
+
+ case '=':
+ if (this.lastBlockEl?.tagName === 'P') { this.startSetextWatch('=', p, ch !== '='); return; }
+ this._blockDefault(ch); return;
+
+ case '[': {
+ if (p[1] === '^') {
+ // Footnote definition [^id]:
+ const ci = p.indexOf(']:');
+ if (ci !== -1 && ci >= 3) {
+ const id = p.slice(2, ci);
+ this.closeBlock();
+ if (!this.footnoteDefs[id]) {
+ const span = document.createElement('span');
+ span.dataset.fn = id; span.style.display = 'none';
+ this.root.appendChild(span);
+ this.footnoteDefs[id] = span; this.footnoteOrder.push(id);
+ }
+ this.dom.toRoot(); this.dom.current = this.footnoteDefs[id];
+ this.inFootnoteDef = true; this.footnoteDefId = id;
+ this._bd(); return;
+ }
+ if (!p.includes(']') || p[p.length - 1] === ']') return;
+ this.fallbackToParagraph(); return;
+ }
+ // Reference link definition [label]:
+ if (!p[1]) return;
+ const ci = p.indexOf(']:');
+ if (ci > 1) { this.defPending = { type: 'ref', key: p.slice(1, ci).toLowerCase(), value: '' }; this._bd(); return; }
+ if (!p.includes(']') || p[p.length - 1] === ']') return;
+ this.fallbackToParagraph(); return;
+ }
+
+ case '<': {
+ if (p.length === 1) return;
+ if (p.startsWith('<!--')) {
+ if (p.endsWith('-->')) { this._bd(); return; }
+ if (p.length < 7) return;
+ this.closeBlock(); this.inRawHtml = true; this.rawHtmlBuf = p; this.rawHtmlTag = '!--';
+ this._bd(); return;
+ }
+ if (p.search(/[>\s/]/, 1) === -1) return;
+ const m = p.match(/^<\/?([a-zA-Z][a-zA-Z0-9-]*)/);
+ if (m && BLOCK_TAGS.has(m[1].toLowerCase())) {
+ this.closeBlock(); this.inRawHtml = true; this.rawHtmlBuf = p; this.rawHtmlTag = null;
+ this._bd(); return;
+ }
+ this.fallbackToParagraph(); return;
+ }
+
+ case ':': {
+ if (p.length === 1) return;
+ if (p[1] === ' ' && this.lastBlockEl) {
+ if (this.lastBlockEl.tagName === 'P') { this.convertLastPToDt(); this._bd(); return; }
+ if (this.lastBlockEl.tagName === 'DD') {
+ const dlEl = this.dom.find('DL');
+ if (dlEl) {
+ this.dom.popTo(dlEl);
+ const dd = this.dom.push('dd'); this.lastBlockEl = dd; this.textNode = null;
+ this._bd(); return;
+ }
+ }
+ }
+ this._blockDefault(ch); return;
+ }
+
+ default:
+ // Ordered list
+ if (p[0] >= '1' && p[0] <= '9') {
+ if (p.length === 1) return;
+ if (p.length === 2 && (p[1] === '.' || p[1] === ')')) return;
+ if (p.length === 3 && (p[1] === '.' || p[1] === ')') && p[2] === ' ') {
+ this.openListItem('ol', this.lineIndent); this._bd(); return;
+ }
+ if (p.length >= 2 && p[1] !== '.' && p[1] !== ')') { this.fallbackToParagraph(); return; }
+ return;
+ }
+ this._blockDefault(ch);
+ }
+ }
+
+ // ── Content chars ──────────────────────────────────────────────────────────
+ onContentChar(ch) {
+ if (this.sepWatch && this.inTable) {
+ this.sepBuf += ch;
+ if (ch !== '-' && ch !== ':' && ch !== ' ' && ch !== '|') this.sepFailed = true;
+ }
+ if (this.setextWatch) {
+ if (ch === this.setextChar) this.setextBuf += ch;
+ else { this.setextFailed = true; this.setextBuf += ch; }
+ return;
+ }
+ if (this.hrWatch) {
+ if (ch === this.hrChar) this.hrCount++;
+ else if (ch !== ' ') this.hrFailed = true;
+ return;
+ }
+ if (this.defPending) { this.defPending.value += ch; return; }
+ if (this.inTable && ch === '|') {
+ this.flushInlinePending();
+ if (this.bareUrlOpen) this.closeBareUrl();
+ this.textNode = null;
+ if (this.inCell) { this.dom.pop(); this.inCell = false; }
+ this.tablePipePending = true; this.tableColIndex++;
+ return;
+ }
+ if (this.inTable && this.tablePipePending) { this.tablePipePending = false; this.openTableCell(); }
+ this.onInlineChar(ch);
+ }
+
+ // ── Inline state machine ───────────────────────────────────────────────────
+ onInlineChar(ch) {
+ // Task list checkbox
+ if (this.taskCheckBuf !== null && !this.taskCheckDone) {
+ this.taskCheckBuf += ch;
+ const b = this.taskCheckBuf;
+ if (b.length === 1 && b !== '[') {
+ this._doneTaskCheck(); // fall through
+ } else if (b.length <= 3 && !['[ ','[x','[X','[ ]','[x]','[X]'].some(s => b === s || s.startsWith(b))) {
+ this._doneTaskCheck(); this.writeText(b); return;
+ } else if (b.length === 4) {
+ if (b === '[ ] ' || b === '[x] ' || b === '[X] ') {
+ const li = this.dom.find('LI');
+ if (li) li.classList.add('task-item');
+ const cb = document.createElement('input');
+ cb.type = 'checkbox'; cb.disabled = true; cb.checked = b[1].toLowerCase() === 'x';
+ this.dom.current.appendChild(cb); this.textNode = null;
+ } else { this.writeText(b); }
+ this._doneTaskCheck(); return;
+ } else { return; }
+ // b.length===1 && b!=='[': fall through to normal inline
+ }
+
+ // Inline code
+ if (this.dom.current._mdMarker === '`') {
+ if (ch === '`') { this.dom.pop(); this.textNode = null; }
+ else this.appendToTextNode(ch);
+ return;
+ }
+
+ if (this.linkState !== null) { this.onLinkChar(ch); return; }
+
+ // Backslash escape
+ if (this.escapeNext) { this.escapeNext = false; this.appendToTextNode(ch); this.prevCharWs = false; return; }
+ if (ch === '\\') { this.escapeNext = true; return; }
+
+ // HTML entity
+ if (this.entityBuf !== null) {
+ this.entityBuf += ch;
+ if (ch === ';') { this.writeText(this.decodeEntity(this.entityBuf)); this.entityBuf = null; this.prevCharWs = false; return; }
+ if (this.entityBuf.length > 12) { this.writeText(this.entityBuf); this.entityBuf = null; }
+ return;
+ }
+ if (ch === '&') { this.entityBuf = '&'; return; }
+
+ // Autolink / inline HTML
+ if (this.autolinkBuf !== null) {
+ if (this.autolinkBuf === '!-' && ch === '-') { this.autolinkBuf = '!--'; return; }
+ if (this.autolinkBuf.startsWith('!--')) {
+ this.autolinkBuf += ch;
+ if (this.autolinkBuf.endsWith('-->')) { this.autolinkBuf = null; this.prevCharWs = false; }
+ return;
+ }
+ if (ch === '>') {
+ const buf = this.autolinkBuf;
+ if (buf.startsWith('/') && /^\/\w+$/.test(buf)) {
+ const el = this.dom.find(buf.slice(1).toUpperCase());
+ if (el) this._pop(el);
+ this.textNode = null; this.autolinkBuf = null; this.prevCharWs = false; return;
+ }
+ const tagMatch = buf.match(/^(\w+)(\s|$)/);
+ const knownInline = ['KBD','SPAN','SUP','SUB','ABBR','CITE','CODE','B','I','U','S','MARK','SMALL','DEL','INS','Q','VAR'];
+ if (tagMatch && knownInline.includes(tagMatch[1].toUpperCase())) {
+ this.dom.push(tagMatch[1]); this.textNode = null;
+ this.autolinkBuf = null; this.prevCharWs = false; return;
+ }
+ if (buf.includes('://') || buf.includes('@')) {
+ const a = document.createElement('a');
+ a.href = buf.includes('@') && !buf.startsWith('http') ? 'mailto:' + buf : buf;
+ this.initAnchor(a); a.appendChild(document.createTextNode(buf));
+ this.dom.current.appendChild(a); this.textNode = null;
+ this.autolinkBuf = null; this.prevCharWs = false; return;
+ }
+ this.appendToTextNode('<' + buf + '>');
+ this.autolinkBuf = null; this.prevCharWs = false; return;
+ }
+ if (ch === ' ' || ch === '<') {
+ this.appendToTextNode('<' + this.autolinkBuf); this.autolinkBuf = null;
+ if (ch === '<') this.autolinkBuf = ''; return;
+ }
+ this.autolinkBuf += ch; return;
+ }
+ if (ch === '<') { this.autolinkBuf = ''; return; }
+
+ // Bare URL
+ if (this.bareUrlOpen) {
+ if (ch === ' ' || ch === '\n' || (ch === ')' && !this.bareUrlParens)) {
+ this.closeBareUrl();
+ if (ch === ' ') this.appendToTextNode(ch);
+ } else {
+ if (ch === '(') this.bareUrlParens = (this.bareUrlParens || 0) + 1;
+ if (ch === ')') this.bareUrlParens = Math.max(0, (this.bareUrlParens || 0) - 1);
+ this.appendToTextNode(ch);
+ }
+ this.prevCharWs = (ch === ' '); return;
+ }
+ if (this.bareUrlBuf !== null) {
+ this.bareUrlBuf += ch;
+ const b = this.bareUrlBuf;
+ const prefixes = ['https://', 'http://'];
+ if (!prefixes.some(pfx => pfx.startsWith(b) || b.startsWith(pfx))) {
+ this.writeText(b); this.bareUrlBuf = null; this.prevCharWs = false; return;
+ }
+ if (prefixes.find(pfx => b.startsWith(pfx))) {
+ const a = this.dom.push('a'); this.initAnchor(a);
+ this.textNode = null; this.writeText(b);
+ this.bareUrlBuf = null; this.bareUrlOpen = true; this.bareUrlParens = 0;
+ }
+ return;
+ }
+ if (this.prevCharWs && ch === 'h') { this.bareUrlBuf = 'h'; this.prevCharWs = false; return; }
+
+ // ==highlight==
+ if (ch === '=') {
+ if (this.inlinePending && this.inlinePending[0] === '=') {
+ this.inlinePending += ch;
+ if (this.inlinePending.length > 2) this.flushInlinePending();
+ return;
+ }
+ if (!this.inlinePending) { this.inlinePending = '='; return; }
+ this.resolveInlinePending(ch); return;
+ }
+
+ if (ch === '!') { this.linkState = 'bang'; this.linkIsImage = true; this.prevCharWs = false; return; }
+ if (ch === '[') {
+ const a = this.dom.push('a'); this.initAnchor(a);
+ this.textNode = null;
+ this.linkState = 'label_open'; this.urlBuf = ''; this.linkBuf = '';
+ this.prevCharWs = false; return;
+ }
+
+ if (this.isMarkerChar(ch)) {
+ if (this.inlinePending && ch !== this.inlinePending[0]) this.resolveInlinePending(null);
+ this.inlinePending += ch;
+ this.prevCharWs = false; return;
+ }
+
+ if (this.inlinePending) { this.resolveInlinePending(ch); this.prevCharWs = (ch === ' '); return; }
+ this.appendToTextNode(ch);
+ this.prevCharWs = (ch === ' ');
+ }
+
+ isMarkerChar(ch) { return '`*_~^'.includes(ch); }
+
+ closeBareUrl() {
+ const a = this.dom.find('A');
+ if (a && !a.href) {
+ const text = a.textContent;
+ const m = text.match(/[.,!?)\]>]+$/);
+ if (m) {
+ a.textContent = text.slice(0, -m[0].length);
+ a.href = a.textContent;
+ this._pop(a); this._resetBareUrl();
+ this.writeText(m[0]); return;
+ }
+ a.href = text.trim();
+ this._pop(a);
+ }
+ this._resetBareUrl();
+ }
+ _resetBareUrl() { this.bareUrlOpen = false; this.bareUrlBuf = null; this.bareUrlParens = 0; this.textNode = null; }
+
+ // ── Link / image state machine ─────────────────────────────────────────────
+ onLinkChar(ch) {
+ switch (this.linkState) {
+ case 'bang':
+ if (ch === '[') { this.linkState = 'img_alt'; this.linkBuf = ''; this.urlBuf = ''; }
+ else { this.linkState = null; this.linkIsImage = false; this.appendToTextNode('!'); this.onInlineChar(ch); }
+ return;
+
+ case 'img_alt':
+ if (ch === ']') this.linkState = 'img_expect_paren';
+ else this.linkBuf += ch;
+ return;
+
+ case 'img_expect_paren':
+ if (ch === '(') { this.linkState = 'img_url'; this.urlBuf = ''; }
+ else { this.appendToTextNode('![' + this.linkBuf + ']' + ch); this.linkState = null; this.linkIsImage = false; this.linkBuf = ''; }
+ return;
+
+ case 'img_url':
+ if (ch === ')') {
+ const { url: iUrl, title: iTitle } = this._parseUrlBuf();
+ const img = document.createElement('img');
+ img.src = iUrl; img.alt = this.linkBuf;
+ if (iTitle) img.title = iTitle;
+ const insideLink = !!this.dom.find('A');
+ if (!insideLink) img.className = 'blk';
+ this.dom.current.appendChild(img);
+ this.textNode = null; this.linkBuf = ''; this.urlBuf = ''; this.linkIsImage = false;
+ this.linkState = insideLink ? 'label_open' : null;
+ } else { this.urlBuf += ch; }
+ return;
+
+ case 'label_open':
+ if (ch === '!') { this.linkState = 'bang'; this.linkIsImage = true; return; }
+ if (ch === ']') {
+ this.flushInlinePending();
+ this._popMarkers(); this.textNode = null;
+ const a = this.dom.find('A'); if (a) this.linkBuf = a.textContent;
+ this.linkState = 'expect_paren'; return;
+ }
+ if (ch === '^' && this.linkBuf === '') {
+ this.abortLinkElement(null);
+ this.linkState = 'fn_ref'; this.linkBuf = ''; return;
+ }
+ this.linkState = null; this.onInlineChar(ch); this.linkState = 'label_open';
+ return;
+
+ case 'fn_ref':
+ if (ch === ']') {
+ const id = this.linkBuf;
+ const a = document.createElement('a');
+ a.className = 'fn-ref'; a.dataset.fnid = id; a.href = '#fn-' + id;
+ a.appendChild(document.createTextNode('?'));
+ this.dom.current.appendChild(a); this.textNode = null;
+ this.linkState = null; this.linkBuf = '';
+ } else { this.linkBuf += ch; }
+ return;
+
+ case 'expect_paren':
+ if (ch === '(') { this.linkState = 'url'; this.urlBuf = ''; }
+ else if (ch === '[') { this.linkState = 'ref_id'; this.urlBuf = ''; }
+ else {
+ const a = this.dom.find('A');
+ if (a) { a.dataset.implicitRef = this.linkBuf.toLowerCase(); this._pop(a); }
+ this._resetLinkUrl();
+ if (ch !== '\n') this.onInlineChar(ch);
+ }
+ return;
+
+ case 'ref_id':
+ if (ch === ']') {
+ const refKey = (this.urlBuf.trim() || this.linkBuf.trim()).toLowerCase();
+ const url = this.refDefs[refKey];
+ const a = this.dom.find('A');
+ if (a) {
+ if (url) a.href = url;
+ else { a.href = '#'; a.dataset.refKey = refKey; }
+ this._pop(a);
+ }
+ this._resetLinkUrl();
+ } else { this.urlBuf += ch; }
+ return;
+
+ case 'url':
+ if (ch === ')') {
+ const { url, title } = this._parseUrlBuf();
+ const a = this.dom.find('A');
+ if (a) { a.href = url; if (title) a.title = title; this._pop(a); }
+ this._resetLinkUrl();
+ } else { this.urlBuf += ch; }
+ return;
+ }
+ }
+
+ abortLinkElement(extraCh) {
+ const a = this.dom.find('A');
+ if (a) {
+ const parent = a.parentNode;
+ if (parent) { while (a.firstChild) parent.insertBefore(a.firstChild, a); parent.removeChild(a); }
+ this.dom.current = parent || this.dom.bottomStack;
+ }
+ this._resetLinkUrl();
+ if (extraCh !== null) this.appendToTextNode(extraCh);
+ }
+
+ _parseUrlBuf() {
+ const raw = this.urlBuf.trim();
+ const m = raw.match(/^(.*?)\s+["'](.*?)["']$/);
+ return { url: m ? m[1] : raw, title: m ? m[2] : null };
+ }
+
+ // ── Resolve inline pending ─────────────────────────────────────────────────
+ resolveInlinePending(nextCh) {
+ const marker = this.inlinePending; this.inlinePending = '';
+ if (marker) {
+ if (marker === '***') {
+ const closeEl = this.findInlineClose('***');
+ if (closeEl !== null) {
+ this._pop(closeEl); this.textNode = null;
+ if (this.dom.current._mdMarker === '***_em') { this.dom.pop(); this.textNode = null; }
+ } else {
+ const strong = this.dom.push('strong'); strong._mdMarker = '***'; this.textNode = null;
+ const em = this.dom.push('em'); em._mdMarker = '***_em'; this.textNode = null;
+ }
+ } else {
+ const closeEl = this.findInlineClose(marker);
+ if (closeEl !== null) {
+ this._pop(closeEl); this.textNode = null;
+ } else {
+ const tag = this.markerToTag(marker);
+ if (tag) {
+ if (tag === 'strong+em') {
+ const strong = this.dom.push('strong'); strong._mdMarker = marker;
+ const em = document.createElement('em'); em._mdMarker = '***_em';
+ strong.appendChild(em); this.dom.current = em; this.textNode = null;
+ } else {
+ const el = this.dom.push(tag); el._mdMarker = marker; this.textNode = null;
+ }
+ } else {
+ this.writeText(marker);
+ }
+ }
+ }
+ }
+ if (nextCh !== null) this.appendToTextNode(nextCh);
+ }
+
+ markerToTag(marker) {
+ return {'**':'strong','*':'em','__':'u','_':'em','~~':'s','^':'sup','~':'sub','`':'code','==':'mark'}[marker] || null;
+ }
+
+ findInlineClose(marker) {
+ let el = this.dom.current;
+ while (el) {
+ if (el._mdMarker === marker) return el;
+ if (el === this.dom.bottomStack) break;
+ el = el.parentNode;
+ }
+ return null;
+ }
+
+ flushInlinePending() {
+ if (!this.inlinePending) return;
+ const marker = this.inlinePending; this.inlinePending = '';
+ const closeEl = this.findInlineClose(marker);
+ if (closeEl !== null) { this._pop(closeEl); this.textNode = null; }
+ else this.writeText(marker);
+ }
+
+ // ── Small shared helpers ───────────────────────────────────────────────────
+ makeHr() { this.closeBlock(); this.dom.current.appendChild(document.createElement('hr')); this.lastBlockEl = null; }
+ fallbackToParagraph() { this.closeBlock(); this.openParagraph(); this.blockDecided = true; this.feedPendingAsInline(); }
+ initAnchor(a) { a.target = '_blank'; a.rel = 'noopener noreferrer'; }
+ flushDefPending() { if (!this.defPending) return; const d = this.defPending; if (d.type === 'ref') this.refDefs[d.key] = d.value.trim(); if (d.type === 'abbr') this.abbrMap[d.key] = d.value.trim(); this.defPending = null; }
+ startHrWatch(c,n,f) { this.hrWatch = true; this.hrChar = c; this.hrCount = n; this.hrFailed = f; this._bd(); }
+ startSetextWatch(c,b,f){ this.setextWatch = true; this.setextChar = c; this.setextBuf = b; this.setextFailed = f; this._bd(); }
+ openUlDecided(s) { this.openListItem('ul', this.lineIndent); this._bd(); for (const c of s) this.onInlineChar(c); }
+
+ // ── Entity decoder ─────────────────────────────────────────────────────────
+ decodeEntity(raw) {
+ if (ENTITY_MAP[raw]) return ENTITY_MAP[raw];
+ if (raw.startsWith('&#x')) try { return String.fromCodePoint(parseInt(raw.slice(3,-1), 16)); } catch(e) {}
+ if (raw.startsWith('&#')) try { return String.fromCodePoint(parseInt(raw.slice(2,-1), 10)); } catch(e) {}
+ return raw;
+ }
+
+ // ── Text helpers ───────────────────────────────────────────────────────────
+ writeText(str) {
+ if (!this.textNode) { this.textNode = document.createTextNode(''); this.dom.current.appendChild(this.textNode); }
+ this.textNode.data += str;
+ }
+ appendToTextNode(ch) { this.writeText(ch); }
+ feedPendingAsInline() { const s = this.pending; this.pending = ''; for (const ch of s) this.onInlineChar(ch); }
+
+ // ── Code fence ─────────────────────────────────────────────────────────────
+ onCodeFenceNewline() {
+ if (!this.dom.find('PRE')) {
+ const lang = (this.fencePrefix || '').trim().split(/\s+/)[0];
+ this.fencePrefix = null;
+ const pre = this.dom.push('pre');
+ const code = document.createElement('code');
+ if (lang) code.className = 'language-' + lang;
+ pre.appendChild(code);
+ this.textNode = document.createTextNode(''); code.appendChild(this.textNode);
+ this.closingFenceBuf = ''; return;
+ }
+ if (this.closingFenceBuf === this.fenceChar.repeat(this.fenceCount || 3)) {
+ this.inCodeFence = false; this.closingFenceBuf = null; this.textNode = null;
+ const pre = this.dom.find('PRE'); if (pre) this._pop(pre);
+ this.lastBlockEl = null; this.resetLine(); return;
+ }
+ if (this.textNode) this.textNode.data += '\n';
+ this.closingFenceBuf = '';
+ }
+
+ feedCodeFenceLine(ch) {
+ this.closingFenceBuf += ch;
+ const fc = this.fenceCount || 3;
+ const fence = this.fenceChar.repeat(fc);
+ if (this.closingFenceBuf === fence) return;
+ if (this.closingFenceBuf.startsWith(this.fenceChar) && this.closingFenceBuf.length <= fc) return;
+ if (this.textNode) this.textNode.data += ch;
+ }
+
+ // ── Raw HTML passthrough ───────────────────────────────────────────────────
+ flushRawHtml() {
+ try {
+ const doc = new DOMParser().parseFromString(this.rawHtmlBuf.trim(), 'text/html');
+ const body = doc.body;
+ while (body.firstChild) this.dom.current.appendChild(document.adoptNode(body.firstChild));
+ } catch(e) { this.writeText(this.rawHtmlBuf); }
+ this.inRawHtml = false; this.rawHtmlBuf = ''; this.rawHtmlTag = null; this.resetLine();
+ }
+
+ // ── Block helpers ──────────────────────────────────────────────────────────
+ closeBlock() {
+ this.flushInlinePending();
+ if (this.bareUrlOpen) this.closeBareUrl();
+ this._popMarkers();
+ this.textNode = null;
+ if (this.dom.depth() > 1) this.dom.toRoot();
+ if (this.inTable) { this.inTable = false; this.tableHeadDone = false; this.inCell = false; this.tableColAlign = []; this.tableColIndex = 0; }
+ this.inFootnoteDef = false;
+ this.inIndentCode = false; this.pendingIndentNL = 0;
+ }
+
+ openParagraph() { const p = this.dom.push('p'); this.lastBlockEl = p; this.textNode = null; }
+
+ ensureBlockquote(level) {
+ this.flushInlinePending();
+ this._popMarkers();
+ this.textNode = null;
+ if (this.dom.currentTag() === 'P') this.dom.pop();
+ let depth = 0, _e = this.dom.current;
+ while (_e) { if (_e.tagName === 'BLOCKQUOTE') depth++; if (_e === this.dom.bottomStack) break; _e = _e.parentNode; }
+ if (depth === 0 && this.dom.depth() > 1) this.dom.toRoot();
+ while (depth < level) { this.dom.push('blockquote'); depth++; }
+ while (depth > level) {
+ if (this.dom.currentTag() === 'P') this.dom.pop();
+ if (this.dom.currentTag() === 'BLOCKQUOTE') this.dom.pop();
+ depth--;
+ }
+ }
+
+ convertLastPToDt() {
+ const p = this.lastBlockEl;
+ const prev = p?.previousElementSibling;
+ const dl = (prev?.tagName === 'DL') ? prev : (() => {
+ const newDl = document.createElement('dl');
+ if (p?.parentNode) p.parentNode.insertBefore(newDl, p);
+ else this.dom.current.appendChild(newDl);
+ return newDl;
+ })();
+ const dt = document.createElement('dt');
+ while (p?.firstChild) dt.appendChild(p.firstChild);
+ p?.parentNode?.removeChild(p);
+ dl.appendChild(dt);
+ if (this.dom.current === p) this.dom.current = dl;
+ const dd = this.dom.push('dd'); this.lastBlockEl = dd; this.textNode = null;
+ }
+
+ openListItem(type, indent) {
+ this._popMarkers();
+ this.textNode = null;
+ if (this.listStack.length === 0) {
+ const tag = this.dom.currentTag();
+ if (!['UL','OL','LI'].includes(tag)) this.closeBlock();
+ this.pushNewList(type, indent);
+ } else {
+ const top = this.listStack[this.listStack.length - 1];
+ if (indent > top.indent) {
+ this.pushNewList(type, indent);
+ } else {
+ if (indent < top.indent) {
+ while (this.listStack.length > 1 && this.listStack[this.listStack.length - 1].indent > indent) {
+ this.listStack.pop();
+ if (this.dom.currentTag() === 'LI') this.dom.pop();
+ if (['UL','OL'].includes(this.dom.currentTag())) this.dom.pop();
+ }
+ }
+ if (this.dom.currentTag() === 'LI') this.dom.pop();
+ const now = this.listStack[this.listStack.length - 1];
+ if (now.type !== type) {
+ if (['UL','OL'].includes(this.dom.currentTag())) this.dom.pop();
+ this.listStack.pop(); this.pushNewList(type, indent);
+ }
+ }
+ }
+ const li = this.dom.push('li'); this.lastBlockEl = li;
+ this.taskCheckBuf = ''; this.taskCheckDone = false;
+ }
+
+ pushNewList(type, indent) {
+ const list = this.dom.push(type);
+ this.listStack.push({ el: list, type, indent });
+ }
+
+ // ── Setext ─────────────────────────────────────────────────────────────────
+ resolveSetext(tag) {
+ const p = this.lastBlockEl;
+ if (!p || p.tagName !== 'P') return;
+ const h = document.createElement(tag);
+ while (p.firstChild) h.appendChild(p.firstChild);
+ p.parentNode.replaceChild(h, p);
+ this.dom.replaceAt(p, h);
+ this.lastBlockEl = h; this.textNode = null;
+ }
+ flushSetextAsFallback() { this.closeBlock(); this.openParagraph(); this.writeText(this.setextBuf); }
+ flushHrAsFallback() { this.closeBlock(); this.openParagraph(); this.writeText(this.hrChar.repeat(this.hrCount)); }
+
+ // ── Table ──────────────────────────────────────────────────────────────────
+ openTable() {
+ this.inTable = true; this.tableHeadDone = false; this.tableColAlign = []; this.tableColIndex = 0;
+ this.dom.push('table'); this.dom.push('thead'); this.dom.push('tr');
+ }
+
+ startTableRow() {
+ const tr = this.dom.find('TR');
+ if (tr) this._pop(tr);
+ this.textNode = null; this.inCell = false; this.tableColIndex = 0;
+ const container = this.dom.find(this.tableHeadDone ? 'TBODY' : 'THEAD');
+ if (container) this.dom.popTo(container);
+ this.dom.push('tr');
+ this.sepWatch = true; this.sepFailed = false;
+ this.sepRowEl = this.dom.find('TR'); this.sepBuf = '';
+ }
+
+ openTableCell() {
+ const tag = this.tableHeadDone ? 'td' : 'th';
+ const cell = this.dom.push(tag);
+ const align = this.tableColAlign[this.tableColIndex] || '';
+ if (align) cell.className = 'align-' + align;
+ this.textNode = null; this.inCell = true;
+ }
+
+ applyTableSep(buf) {
+ const cells = buf.replace(/^\||\|$/g, '').split('|');
+ this.tableColAlign = cells.map(c => {
+ c = c.trim();
+ if (c.startsWith(':') && c.endsWith(':')) return 'center';
+ if (c.endsWith(':')) return 'right';
+ if (c.startsWith(':')) return 'left';
+ return '';
+ });
+ const thead = this.dom.find('THEAD');
+ if (thead) thead.querySelectorAll('th').forEach((th, i) => { if (this.tableColAlign[i]) th.className = 'align-' + this.tableColAlign[i]; });
+ }
+
+ // ── Finalize ───────────────────────────────────────────────────────────────
+ finalize() {
+ this.flushDefPending();
+ this.flushInlinePending();
+ if (this.bareUrlOpen) this.closeBareUrl();
+ this.textNode = null;
+
+ this.root.querySelectorAll('a[href="#"]').forEach(a => {
+ const key = a.dataset.refKey || a.textContent.trim().toLowerCase();
+ if (this.refDefs[key]) { a.href = this.refDefs[key]; delete a.dataset.refKey; }
+ });
+
+ this.root.querySelectorAll('a[data-implicit-ref]').forEach(a => {
+ const key = a.dataset.implicitRef;
+ if (this.refDefs[key]) {
+ a.href = this.refDefs[key]; a.removeAttribute('data-implicit-ref');
+ } else {
+ const parent = a.parentNode;
+ if (parent) {
+ parent.insertBefore(document.createTextNode('[' + a.textContent + ']'), a);
+ parent.removeChild(a);
+ }
+ }
+ });
+
+ if (Object.keys(this.abbrMap).length > 0) this.applyAbbrs(this.root);
+ this.renderFootnotes();
+ }
+
+ applyAbbrs(root) {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
+ for (const node of nodes) {
+ if (!node.parentElement) continue;
+ const tag = node.parentElement.tagName;
+ if (['CODE','PRE','A','ABBR','KBD'].includes(tag)) continue;
+ let segments = [{text: node.data, title: null}];
+ for (const [abbr, title] of Object.entries(this.abbrMap)) {
+ const re = new RegExp('(?<![\\w])' + abbr.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + '(?![\\w])', 'g');
+ const next = [];
+ for (const seg of segments) {
+ if (seg.title !== null) { next.push(seg); continue; }
+ let last = 0; re.lastIndex = 0; let m;
+ while ((m = re.exec(seg.text)) !== null) {
+ if (m.index > last) next.push({text: seg.text.slice(last, m.index), title: null});
+ next.push({text: m[0], title});
+ last = m.index + m[0].length;
+ }
+ if (last < seg.text.length) next.push({text: seg.text.slice(last), title: null});
+ else if (last === 0) next.push(seg);
+ }
+ segments = next;
+ }
+ if (segments.length === 1 && segments[0].title === null) continue;
+ const frag = document.createDocumentFragment();
+ for (const seg of segments) {
+ if (seg.title === null) frag.appendChild(document.createTextNode(seg.text));
+ else { const el = document.createElement('abbr'); el.title = seg.title; el.appendChild(document.createTextNode(seg.text)); frag.appendChild(el); }
+ }
+ node.parentNode.replaceChild(frag, node);
+ }
+ }
+
+ renderFootnotes() {
+ if (!this.footnoteOrder.length) return;
+ const idToNum = {}; let num = 1;
+ this.root.querySelectorAll('.fn-ref').forEach(ref => {
+ const id = ref.dataset.fnid;
+ if (!idToNum[id]) idToNum[id] = num++;
+ ref.textContent = '[' + idToNum[id] + ']';
+ ref.href = '#fn-' + id;
+ });
+ const section = document.createElement('div');
+ section.className = 'footnotes';
+ const ol = document.createElement('ol');
+ section.appendChild(ol);
+ for (const id of this.footnoteOrder) {
+ if (!idToNum[id]) continue;
+ const li = document.createElement('li'); li.id = 'fn-' + id;
+ const span = this.footnoteDefs[id];
+ if (span) { while (span.firstChild) li.appendChild(span.firstChild); span.remove(); }
+ ol.appendChild(li);
+ }
+ this.root.appendChild(section);
+ }
+
+ // ── Streaming control ──────────────────────────────────────────────────────
+ run = true; speed = 100; delay = 0; batch = 4;
+
+ setSpeed(s) { this.speed = s; this.delay = Math.max(0, 80 - s * 0.80); this.batch = s > 80 ? 4 : 1; }
+ stop() { this.run = false; }
+
+ async markdownasync(text) {
+ this.run = true;
+ for (let index = 0; index < text.length;) {
+ if (!this.run) return;
+ for (let b = 0; b < this.batch && index < text.length; b++) {
+ if (!this.run) return;
+ this.processChar(text[index++]);
+ }
+ if (this.delay > 0) await new Promise(resolve => setTimeout(resolve, this.delay));
+ }
+ }
+
+ markdown(text) { for (const char of text) this.processChar(char); }
+}
diff --git a/tests/md4-light.css b/tests/md4-light.css
new file mode 100644
index 0000000..0f9dbab
--- /dev/null
+++ b/tests/md4-light.css
@@ -0,0 +1,27 @@
+/* Light theme for .pane-output — standalone usable, include after md4.css or on its own */
+.pane-output {
+ --accent: #0070f3;
+ --text: #111827;
+ --text-dim: #6b7280;
+ --code-bg: #f3f4f6;
+ --border: #e5e7eb;
+ background: #ffffff;
+ color: #111827;
+}
+.pane-output strong { color: #000; }
+.pane-output code { color: #0550ae; background: #f3f4f6; }
+.pane-output pre { background: #f8f9fa; border-color: #e5e7eb; border-left-color: #0070f3; color: #24292f; }
+.pane-output pre code { color: inherit; background: none; }
+.pane-output blockquote { border-left-color: #0070f3; color: #6b7280; }
+.pane-output blockquote blockquote { border-left-color: #5588cc; }
+.pane-output blockquote blockquote blockquote { border-left-color: #9966aa; }
+.pane-output a { color: #0070f3; text-decoration-color: rgba(0,112,243,.3); }
+.pane-output a:hover { color: #0051bb; text-decoration-color: #0051bb; }
+.pane-output dt { color: #111827; }
+.pane-output th { background: #f3f4f6; color: #0070f3; }
+.pane-output tr:nth-child(even) td { background: #fafafa; }
+.pane-output kbd { background: #f3f4f6; border-color: #d1d5db; color: #111827; }
+.pane-output summary { color: #0070f3; }
+.pane-output .fn-ref { color: #0070f3; }
+.pane-output li.task-item input[type=checkbox] { border-color: #111827; }
+.pane-output li.task-item input[type=checkbox]:checked::after { border-color: #fff; }
diff --git a/tests/md4.css b/tests/md4.css
new file mode 100644
index 0000000..5211db6
--- /dev/null
+++ b/tests/md4.css
@@ -0,0 +1,105 @@
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&display=swap');
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ :root {
+ --bg:#0e0e10; --surface:#16161a; --border:#2a2a30;
+ --accent:#00e5a0; --text:#d4d4d8; --text-dim:#71717a; --code-bg:#1e1e24;
+ --mono:'JetBrains Mono',monospace; --serif:'Newsreader',Georgia,serif;
+ }
+ html { height:100%; overflow:hidden; }
+ body { background:var(--bg); color:var(--text); font-family:var(--serif); font-size:17px; line-height:1.7; height:100vh; overflow:hidden; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:auto 1fr; }
+ header { grid-column:1/-1; padding:18px 32px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; background:var(--surface); }
+ header h1 { font-family:var(--mono); font-size:13px; font-weight:700; color:var(--accent); letter-spacing:.12em; text-transform:uppercase; }
+ .controls { display:flex; gap:16px; align-items:center; }
+ label { font-family:var(--mono); font-size:11px; color:var(--text-dim); letter-spacing:.08em; }
+ input[type=range] { accent-color:var(--accent); width:80px; }
+ button { font-family:var(--mono); font-size:11px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; background:var(--accent); color:#000; border:none; padding:7px 18px; cursor:pointer; transition:opacity .15s; }
+ button:hover { opacity:.85; } button:disabled { opacity:.3; cursor:default; }
+ .pane { padding:32px 40px; overflow-y:auto; }
+ .pane-source { border-right:1px solid var(--border); }
+ .pane-source textarea { width:100%; min-height:100%; field-sizing:content; overflow:hidden; background:transparent; border:none; outline:none; color:var(--text); font-family:var(--mono); font-size:13px; line-height:1.8; resize:none; caret-color:var(--accent); }
+ .pane-output h1 { font-size:2em; font-weight:600; line-height:1.2; margin:.6em 0 .4em; border-bottom:2px solid var(--accent); padding-bottom:.2em; }
+ .pane-output h2 { font-size:1.5em; font-weight:600; margin:.8em 0 .3em; border-bottom:1px solid var(--border); padding-bottom:.2em; }
+ .pane-output h3 { font-size:1.2em; font-weight:600; margin:.8em 0 .3em; }
+ .pane-output h4 { font-size:1em; font-weight:600; margin:.6em 0 .2em; }
+ .pane-output h5 { font-size:.9em; font-weight:600; margin:.5em 0 .2em; }
+ .pane-output h6 { font-size:.85em; font-weight:600; color:var(--text-dim); margin:.5em 0 .2em; }
+ .pane-output p { margin:.5em 0; }
+ .pane-output strong { font-weight:700; color:#fff; }
+ .pane-output em { font-style:italic; }
+ .pane-output u { text-decoration:underline; text-underline-offset:3px; }
+ .pane-output s { text-decoration:line-through; opacity:.7; }
+ .pane-output sup { font-size:.72em; vertical-align:super; line-height:0; }
+ .pane-output sub { font-size:.72em; vertical-align:sub; line-height:0; }
+ .pane-output mark { background:rgba(255,220,50,.25); color:inherit; border-radius:2px; padding:0 2px; }
+ .pane-output code { font-family:var(--mono); font-size:.82em; background:var(--code-bg); padding:1px 6px; border-radius:3px; color:#7dd3fc; }
+ .pane-output pre { background:var(--code-bg); border:1px solid var(--border); border-left:3px solid var(--accent); padding:16px 20px; margin:.8em 0; overflow-x:auto; font-family:var(--mono); font-size:13px; line-height:1.6; color:#a5f3fc; }
+ .pane-output pre code { background:none; padding:0; color:inherit; font-size:1em; }
+ .pane-output blockquote { border-left:3px solid var(--accent); margin:.8em 0; padding:4px 20px; color:var(--text-dim); font-style:italic; }
+ .pane-output blockquote blockquote { border-left-color:#5577aa; margin:.3em 0; }
+ .pane-output blockquote blockquote blockquote { border-left-color:#886699; }
+ .pane-output ul, .pane-output ol { margin:.2em 0 .2em 1.4em; padding:0; }
+ .pane-output ol ol { list-style-type:lower-roman; }
+ .pane-output ol ol ol { list-style-type:lower-alpha; }
+ .pane-output li { margin:.1em 0; }
+ .pane-output li > ul, .pane-output li > ol { margin-top:.1em; margin-bottom:.1em; }
+ .pane-output li.task-item { list-style:none; margin-left:-1em; }
+ .pane-output li.task-item input[type=checkbox] {
+ appearance:none; -webkit-appearance:none;
+ width:.95em; height:.95em; margin-right:.45em;
+ border:2px solid var(--text); border-radius:3px;
+ vertical-align:middle; position:relative; top:-.1em;
+ background:transparent; cursor:default; flex-shrink:0;
+ }
+ .pane-output li.task-item input[type=checkbox]:checked {
+ background:var(--accent); border-color:var(--accent);
+ }
+ .pane-output li.task-item input[type=checkbox]:checked::after {
+ content:''; position:absolute;
+ left:2px; top:-1px; width:5px; height:8px;
+ border:2px solid var(--bg); border-top:none; border-left:none;
+ transform:rotate(45deg);
+ }
+ .pane-output hr { border:none; border-top:1px solid var(--border); margin:1.2em 0; }
+ .pane-output table { border-collapse:collapse; width:100%; margin:.8em 0; font-size:.92em; }
+ .pane-output th { background:var(--code-bg); color:var(--accent); font-family:var(--mono); font-size:.85em; }
+ .pane-output th, .pane-output td { border:1px solid var(--border); padding:7px 14px; text-align:left; }
+ .pane-output .align-center { text-align:center; }
+ .pane-output .align-right { text-align:right; }
+ .pane-output tr:nth-child(even) td { background:#1a1a20; }
+ .pane-output a { color:#60c8ff; text-decoration:underline; text-underline-offset:3px; text-decoration-color:rgba(96,200,255,.35); transition:color .15s,text-decoration-color .15s; }
+ .pane-output a:hover { color:#a8daff; text-decoration-color:#a8daff; }
+ .pane-output img { max-width:100%; vertical-align:middle; border-radius:4px; }
+ .pane-output img.blk { display:block; margin:.5em 0; }
+ .pane-output abbr { text-decoration:underline dotted; cursor:help; }
+ .pane-output details { border:1px solid var(--border); border-radius:4px; padding:8px 16px; margin:.6em 0; }
+ .pane-output summary { cursor:pointer; font-weight:600; color:var(--accent); }
+ .pane-output kbd { font-family:var(--mono); font-size:.8em; background:var(--code-bg); border:1px solid var(--border); border-bottom-width:2px; border-radius:3px; padding:1px 7px; color:var(--text); }
+ .pane-output dl { margin:.5em 0; }
+ .pane-output dt { font-weight:700; color:#fff; margin-top:.5em; }
+ .pane-output dd { margin-left:1.6em; color:var(--text-dim); }
+ .pane-output .fn-ref { font-size:.75em; vertical-align:super; line-height:0; color:#60c8ff; text-decoration:none; }
+ .pane-output .fn-ref:hover { text-decoration:underline; }
+ .pane-output .footnotes { border-top:1px solid var(--border); margin-top:2em; padding-top:1em; font-size:.88em; color:var(--text-dim); }
+ .pane-output .footnotes ol { margin-left:1.4em; }
+ .status { font-family:var(--mono); font-size:11px; color:var(--text-dim); }
+
+ /* ── Light theme for output pane ── */
+ .pane-output.light { --accent:#0070f3; --text:#111827; --text-dim:#6b7280; --code-bg:#f3f4f6; --border:#e5e7eb; background:#fff; color:#111827; }
+ .pane-output.light strong { color:#000; }
+ .pane-output.light code { color:#0550ae; background:#f3f4f6; }
+ .pane-output.light pre { background:#f8f9fa; border-color:#e5e7eb; border-left-color:#0070f3; color:#24292f; }
+ .pane-output.light pre code { color:inherit; background:none; }
+ .pane-output.light blockquote { border-left-color:#0070f3; color:#6b7280; }
+ .pane-output.light blockquote blockquote { border-left-color:#5588cc; }
+ .pane-output.light blockquote blockquote blockquote { border-left-color:#9966aa; }
+ .pane-output.light a { color:#0070f3; text-decoration-color:rgba(0,112,243,.3); }
+ .pane-output.light a:hover { color:#0051bb; text-decoration-color:#0051bb; }
+ .pane-output.light dt { color:#111827; }
+ .pane-output.light th { background:#f3f4f6; color:#0070f3; }
+ .pane-output.light tr:nth-child(even) td { background:#fafafa; }
+ .pane-output.light kbd { background:#f3f4f6; border-color:#d1d5db; color:#111827; }
+ .pane-output.light summary { color:#0070f3; }
+ .pane-output.light .fn-ref { color:#0070f3; }
+ .pane-output.light li.task-item input[type=checkbox] { border-color:#111827; }
+ .pane-output.light li.task-item input[type=checkbox]:checked::after { border-color:#fff; }
+
\ No newline at end of file
diff --git a/tests/md4.html b/tests/md4.html
new file mode 100644
index 0000000..b1c25bb
--- /dev/null
+++ b/tests/md4.html
@@ -0,0 +1,232 @@
+<!DOCTYPE html>
+<html lang="nl">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>MD4 Streamer</title>
+<link rel="stylesheet" href="md4.css?1" />
+<script src="md4.js" defer></script>
+<script src="md4start.js" defer></script>
+</head>
+<body>
+<header>
+ <h1>MD4 Streamer</h1>
+ <div class="controls">
+ <label>Speed <input type="range" id="speedSlider" min="1" max="100" value="90"></label>
+ <span class="status" id="statusLabel">klaar</span>
+ <button id="themeBtn">Light</button>
+ <button id="streamBtn">Stream</button>
+ <button id="stopBtn" disabled>Stop</button>
+ </div>
+</header>
+<div class="pane pane-source">
+<textarea id="source" spellcheck="false"># MD Streamer — Volledig overzicht
+
+## Koppen H1 t/m H6
+
+# H1 Hoofdtitel
+## H2 Sectie
+### H3 Subsectie
+#### H4 Paragraaf
+##### H5 Subparagraaf
+###### H6 Klein kopje
+
+Setext H1
+=========
+
+Setext H2
+---------
+
+## Inline opmaak
+
+**vet**, *cursief*, __onderstreept__, ~~doorgehaald~~, ==gemarkeerd==
+
+`inline code`, x^sup^, H~sub~
+
+Combinaties: **vet en *cursief* samen**, ~~doorgehaald **vet**~~
+
+Backslash escape: \*geen italic\*, \`geen code\`, \[geen link\]
+
+HTML entities: &amp;copy; &amp;amp; &amp;lt; &amp;gt; &amp;euro; &amp;mdash; &amp;#128512;
+
+Toetsen: Druk `Ctrl+S` om op te slaan.
+
+## Hard line break
+
+Eerste regel (twee spaties)
+Tweede regel direct eronder
+
+Backslash break: regel één\
+regel twee direct eronder
+
+## Links
+
+[Gewone link](https://www.google.com)
+
+[Link met **vette** tekst en *cursief*](https://www.anthropic.com)
+
+Autolink: <https://example.com>
+
+E-mail: <info@example.com>
+
+Bare URL: https://www.wikipedia.org wordt automatisch een link.
+
+Referentielink: [Google][google] en [Anthropic][ant]
+
+[google]: https://www.google.com
+[ant]: https://www.anthropic.com "Anthropic website"
+
+## Afbeeldingen
+
+![Random Photo](https://picsum.photos/200/300)
+
+Afbeelding als link:
+
+[![Wikipedia logo](https://nl.wikipedia.org/static/images/icons/wikipedia.png)](https://www.wikipedia.org)
+
+## Blockquotes (genest)
+
+> Niveau 1 — eerste quote
+>
+> Nog steeds niveau 1
+>
+> > Niveau 2 dieper genest
+> >
+> > > Niveau 3 nog dieper
+
+## Lijsten
+
+- Item A
+- Item B
+ - Sub B1
+ - Sub B2
+ - Diep B2a
+- Item C
+
+1. Eerste
+2. Tweede
+ 1. Sub 2a
+ 2. Sub 2b
+3. Derde
+
+Gemengd:
+- Fruit
+ 1. Appel
+ 2. Peer
+- Groente
+
+## Takenlijst
+
+- [x] Gedaan
+- [ ] Open taak
+- [x] Ook klaar
+- [ ] Nog te doen
+
+## Code
+
+```javascript
+function fibonacci(n) {
+ if (n <= 1) return n;
+ return fibonacci(n - 1) + fibonacci(n - 2);
+}
+```
+
+```python
+def hello(name: str) -> str:
+ return f"Hallo, {name}!"
+```
+
+~~~css
+body { color: #0e0e10; }
+~~~
+
+## Tabel met uitlijning
+
+| Links | Midden | Rechts | Standaard |
+|:-------------|:------------:|--------:|-----------|
+| cel A | gecentreerd | 42 | tekst |
+| lang item | midden | 1.234 | gewoon |
+| **vet** | *cursief* | `code` | normaal |
+
+## Horizontale lijn
+
+---
+
+***
+
+## Definitielijst
+
+Markdown
+: Een lichtgewicht opmaaktaal voor het web
+
+HTML
+: HyperText Markup Language
+: De structuurtaal van het web
+
+## Footnotes
+
+Dit heeft een voetnoot.[^1] En een tweede.[^twee]
+
+[^1]: Eerste voetnoot met uitleg.
+[^twee]: Tweede voetnoot, iets langer van tekst.
+
+## Afkortingen
+
+De HTML spec en CSS worden dagelijks gebruikt.
+
+*[HTML]: HyperText Markup Language
+*[CSS]: Cascading Style Sheets
+
+## Details / Samenvatting
+
+<details>
+<summary>Klik om uit te klappen</summary>
+Verborgen inhoud die pas zichtbaar is na klikken.
+</details>
+
+## Nieuw in v4
+
+***Vet én cursief*** tegelijk met triple asterisk.
+
+Thematische breek met spaties:
+
+- - -
+
+_ _ _
+
+Sluitende hashes in heading: `## Titel ##`
+
+## Titel ##
+
+Bestelling met haakjes:
+
+1) Eerste item
+2) Tweede item
+
+Impliciete referentielink: [Google]
+
+[Google]: https://www.google.com
+
+Inline HTML comment: voor <!-- verborgen tekst --> na.
+
+Bare URL met leesteken: ga naar https://example.com. Klaar.
+
+4-spatie code block:
+
+ dit is een code block
+ door 4 spaties inspringing
+
+4-tilde fence:
+
+~~~~
+dit sluit ook met 4 tildes
+~~~~
+
+Einde van de demo.
+</textarea>
+</div>
+<div class="pane">
+ <div id="output" class="pane-output"></div>
+</div>
+</body>
+</html>
diff --git a/tests/md4.js b/tests/md4.js
new file mode 100644
index 0000000..397c082
--- /dev/null
+++ b/tests/md4.js
@@ -0,0 +1,1123 @@
+/**
+ * md v4.0.1 - a markdown streaming parser
+ * Copyright (c) 2025-2026, Alphons van der Heijden
+ * https://github.com/alphons/MarkdownStreamer
+ */
+
+'use strict';
+
+// ─── Module-level constants ────────────────────────────────────────────────────
+const ENTITY_MAP = {
+ '&amp;':'&','&lt;':'<','&gt;':'>','&quot;':'"',"&apos;":"'",'&nbsp;':'\u00a0',
+ '&copy;':'©','&reg;':'®','&trade;':'™','&euro;':'€','&pound;':'£','&yen;':'¥',
+ '&mdash;':'—','&ndash;':'–','&laquo;':'«','&raquo;':'»','&hellip;':'…',
+ '&rarr;':'→','&larr;':'←','&uarr;':'↑','&darr;':'↓',
+ '&times;':'×','&divide;':'÷','&plusmn;':'±','&deg;':'°',
+ '&frac12;':'½','&frac14;':'¼','&frac34;':'¾','&hearts;':'♥','&spades;':'♠',
+};
+const BLOCK_TAGS = new Set(['details','summary','figure','figcaption','aside','section','article','div','table','thead','tbody','tr','td','th','ul','ol','li','dl','dt','dd','form','nav','header','footer','main','blockquote','pre','hr','h1','h2','h3','h4','h5','h6']);
+
+// ─── DomStack ─────────────────────────────────────────────────────────────────
+class DomStack {
+ constructor(root) { this.current = root; this.bottomStack = root; }
+
+ push(nodeName) {
+ const el = document.createElement(nodeName);
+ this.current.appendChild(el);
+ this.current = el;
+ return el;
+ }
+
+ pop() {
+ if (this.current !== this.bottomStack && this.current.parentNode)
+ this.current = this.current.parentNode;
+ return this.current;
+ }
+
+ popTo(el) { this.current = el; }
+ toRoot() { this.current = this.bottomStack; }
+ currentTag() { return this.current.nodeName; }
+
+ find(tag) {
+ const t = tag.toUpperCase();
+ let el = this.current;
+ while (el) {
+ if (el.nodeName === t) return el;
+ if (el === this.bottomStack) break;
+ el = el.parentNode;
+ }
+ return null;
+ }
+
+ depth() {
+ let d = 1, el = this.current;
+ while (el !== this.bottomStack && el.parentNode) { d++; el = el.parentNode; }
+ return d;
+ }
+
+ replaceAt(oldEl, newEl) { if (this.current === oldEl) this.current = newEl; }
+}
+
+// ─── MarkdownStreamer ──────────────────────────────────────────────────────────
+class MarkdownStreamer {
+ constructor(rootEl) {
+ rootEl.innerHTML = '';
+ this.root = rootEl;
+ this.dom = new DomStack(rootEl);
+
+ this.linePos = 0; this.lineIndent = 0; this.blockDecided = false;
+ this.pending = ''; this.lastBlockEl = null; this.lineStart = true;
+ this.listStack = [];
+ this.inlinePending = ''; this.textNode = null;
+ this.escapeNext = false; this.entityBuf = null;
+ this.autolinkBuf = null;
+ this.bareUrlBuf = null; this.bareUrlOpen = false; this.prevCharWs = true;
+ this.linkState = null; this.linkBuf = ''; this.urlBuf = ''; this.linkIsImage = false;
+ this.refDefs = {};
+ this.inCodeFence = false; this.inIndentCode = false; this.pendingIndentNL = 0;
+ this.fenceChar = '`'; this.fencePrefix = ''; this.closingFenceBuf = null;
+ this.inTable = false; this.tableHeadDone = false; this.tableColAlign = [];
+ this.tableColIndex = 0; this.inCell = false; this.tablePipePending = false;
+ this._resetSetext(); this._resetHr();
+ this.trailingSpaces = 0;
+ this.taskCheckBuf = null; this.taskCheckDone = false;
+ this.footnoteDefs = {}; this.footnoteOrder = []; this.inFootnoteDef = false; this.footnoteDefId = '';
+ this.abbrMap = {};
+ this.defPending = null;
+ this.inRawHtml = false; this.rawHtmlBuf = ''; this.rawHtmlTag = null;
+
+ this.sepWatch = false; this.sepFailed = false; this.sepRowEl = null; this.sepBuf = '';
+ }
+
+ // ── Private helpers ────────────────────────────────────────────────────────
+ _bd() { this.blockDecided = true; this.pending = ''; }
+ _pop(el) { this.dom.popTo(el); this.dom.pop(); }
+ _popMarkers() { while (this.dom.current._mdMarker) this.dom.pop(); }
+ _resetLinkUrl() { this.linkState = null; this.urlBuf = ''; this.textNode = null; }
+ _resetSetext() { this.setextWatch = false; this.setextBuf = ''; this.setextChar = ''; this.setextFailed = false; }
+ _resetHr() { this.hrWatch = false; this.hrChar = ''; this.hrCount = 0; this.hrFailed = false; }
+ _doneTaskCheck() { this.taskCheckDone = true; this.taskCheckBuf = null; }
+ _bqLevel(p) {
+ let level = 0, i = 0;
+ while (i < p.length && p[i] === '>') { level++; i++; if (i < p.length && p[i] === ' ') i++; }
+ return { level, i };
+ }
+
+ // ── Public processChar ─────────────────────────────────────────────────────
+ processChar(ch) {
+ if (ch === '\n') { this.onNewline(); return; }
+ if (this.inCodeFence) {
+ if (this.fencePrefix === null) { this.feedCodeFenceLine(ch); return; }
+ this.fencePrefix += ch; return;
+ }
+ if (this.inRawHtml) { this.rawHtmlBuf += ch; return; }
+
+ this.linePos++;
+ if (this.lineStart) this.lineStart = false;
+
+ if (!this.blockDecided) {
+ if (ch === ' ' && this.linePos === this.lineIndent + 1) {
+ this.lineIndent++;
+ if (this.lineIndent === 4 && this.dom.currentTag() !== 'LI') {
+ if (!this.inIndentCode) {
+ this.closeBlock();
+ const pre = this.dom.push('pre');
+ const code = document.createElement('code');
+ pre.appendChild(code);
+ this.textNode = document.createTextNode(''); code.appendChild(this.textNode);
+ this.inIndentCode = true; this.lastBlockEl = pre;
+ }
+ this._bd(); this.lineIndent = 0;
+ }
+ return;
+ }
+ this.inIndentCode = false; this.pendingIndentNL = 0;
+ this.decideBlock(ch); return;
+ }
+
+ if (ch === ' ') this.trailingSpaces++;
+ else this.trailingSpaces = 0;
+ this.onContentChar(ch);
+ }
+
+ // ── Newline ────────────────────────────────────────────────────────────────
+ onNewline() {
+ if (this.defPending) { this.flushDefPending(); this.resetLine(); return; }
+
+ if (this.sepWatch && this.inTable) {
+ this.sepWatch = false;
+ if (!this.sepFailed && this.sepRowEl) {
+ this.applyTableSep('|' + this.sepBuf);
+ const tr = this.sepRowEl;
+ this._pop(tr); tr.remove();
+ const thead = this.dom.find('THEAD');
+ if (thead) this._pop(thead);
+ this.tableHeadDone = true;
+ const tbody = document.createElement('tbody');
+ this.dom.find('TABLE').appendChild(tbody); this.dom.current = tbody;
+ this.textNode = null; this.inCell = false; this.tablePipePending = false; this.tableColIndex = 0;
+ this.sepRowEl = null; this.resetLine(); return;
+ }
+ this.sepRowEl = null;
+ }
+
+ if (this.inCodeFence) { this.onCodeFenceNewline(); return; }
+ if (this.inIndentCode) {
+ if (!this.blockDecided) this.pendingIndentNL++;
+ else { if (this.textNode) this.textNode.data += '\n'.repeat(this.pendingIndentNL) + '\n'; this.pendingIndentNL = 0; }
+ this.resetLine(); return;
+ }
+ if (this.inRawHtml) {
+ this.rawHtmlBuf += '\n';
+ if (!this.rawHtmlTag) {
+ const m = this.rawHtmlBuf.match(/^<([a-zA-Z][a-zA-Z0-9-]*)/);
+ this.rawHtmlTag = m ? m[1].toLowerCase() : null;
+ }
+ if (this.rawHtmlTag && new RegExp('<\\/' + this.rawHtmlTag + '\\s*>$', 'i').test(this.rawHtmlBuf.trimEnd()))
+ this.flushRawHtml();
+ return;
+ }
+
+ if (this.setextWatch) {
+ if (!this.setextFailed && this.setextBuf.length >= 1) this.resolveSetext(this.setextChar === '=' ? 'h1' : 'h2');
+ else this.flushSetextAsFallback();
+ this._resetSetext();
+ this.resetLine(); return;
+ }
+ if (this.hrWatch) {
+ if (!this.hrFailed && this.hrCount >= 3) this.makeHr();
+ else this.flushHrAsFallback();
+ this._resetHr();
+ this.resetLine(); return;
+ }
+
+ if (!this.blockDecided && this.pending) {
+ const p = this.pending;
+ if (p[0] === '>') {
+ const { level } = this._bqLevel(p);
+ if (level > 0) { this.ensureBlockquote(level); this.openParagraph(); }
+ } else if ((p[0] === '`' || p[0] === '~') && p.length >= 3 && p.split('').every(c => c === p[0])) {
+ this.closeBlock(); this.inCodeFence = true; this.fenceChar = p[0];
+ this.fenceCount = p.length; this.fencePrefix = null; this.closingFenceBuf = null;
+ this.onCodeFenceNewline();
+ } else if (p[0] === '*' && /^\*{3,}$/.test(p)) {
+ this.makeHr();
+ } else if (p[0] === '-' && /^-{3,}$/.test(p)) {
+ if (this.lastBlockEl?.tagName === 'P') this.resolveSetext('h2');
+ else this.makeHr();
+ } else if (/^[_ ]+$/.test(p) && (p.match(/_/g)||[]).length >= 3) {
+ this.makeHr();
+ } else if (/^- (- ?)+$/.test(p.trimEnd()) && (p.match(/-/g)||[]).length >= 3) {
+ this.makeHr();
+ } else if (p[0] === '-' && /^- /.test(p)) {
+ this.openUlDecided(p.slice(2));
+ } else if (p[0] === '[') {
+ this.fallbackToParagraph();
+ }
+ this.pending = '';
+ }
+
+ if (!this.blockDecided) {
+ const tag = this.dom.currentTag();
+ if (tag === 'P') { this.dom.pop(); this.textNode = null; }
+ else if (tag === 'LI') { this.dom.toRoot(); this.listStack = []; this.textNode = null; }
+ else if (tag === 'DD') { this.dom.toRoot(); this.textNode = null; }
+ this.resetLine(); return;
+ }
+
+ if ((this.trailingSpaces >= 2 || this.escapeNext) && this.blockDecided) {
+ if (this.textNode) this.textNode.data = this.textNode.data.replace(/ +$/, '');
+ this.dom.current.appendChild(document.createElement('br'));
+ this.textNode = null;
+ }
+ this.escapeNext = false;
+
+ if (this.bareUrlOpen) {
+ const a = this.dom.find('A');
+ if (a && !a.href) { a.href = a.textContent.trim(); this._pop(a); }
+ this._resetBareUrl();
+ }
+
+ this.flushInlinePending();
+ if (this.linkState === 'expect_paren') {
+ const a = this.dom.find('A');
+ if (a && !a.href) { a.dataset.implicitRef = this.linkBuf.toLowerCase(); this._pop(a); }
+ this._resetLinkUrl();
+ } else if (this.linkState !== null) {
+ this.abortLinkElement(null);
+ }
+
+ if (this.atxLevel && this.textNode)
+ this.textNode.data = this.textNode.data.replace(/\s+#+\s*$/, '').replace(/\s+#+$/, '');
+ this.atxLevel = 0;
+ this.textNode = null;
+
+ this._popMarkers();
+ if (this.inFootnoteDef) { this.dom.toRoot(); this.inFootnoteDef = false; this.footnoteDefId = ''; }
+ this.resetLine();
+ }
+
+ resetLine() {
+ this.linePos = 0; this.lineIndent = 0; this.blockDecided = false;
+ this.pending = ''; this.inlinePending = ''; this.atxLevel = 0;
+ this.linkState = null; this.linkBuf = ''; this.urlBuf = ''; this.linkIsImage = false;
+ this.inCell = false; this.tablePipePending = false; this.trailingSpaces = 0;
+ this.lineStart = true; this.prevCharWs = true; this.bareUrlBuf = null;
+ this.escapeNext = false; this.entityBuf = null; this.autolinkBuf = null;
+ this.taskCheckBuf = null; this.taskCheckDone = false;
+ }
+
+ // ── Block decision ─────────────────────────────────────────────────────────
+ _blockDefault(ch) {
+ if (this.defPending) { this.defPending.value += ch; return; }
+ const tag = this.dom.currentTag();
+ if (tag === 'P' || tag === 'LI' || tag === 'DD') { this._bd(); this.feedPendingAsInline(); return; }
+ this.fallbackToParagraph();
+ }
+
+ decideBlock(ch) {
+ this.pending += ch;
+ const p = this.pending;
+
+ switch (p[0]) {
+ case '#':
+ if (ch === '#' && p.length <= 6) return;
+ if (ch === ' ' && p.length >= 2 && /^#{1,6}$/.test(p.slice(0,-1))) {
+ const level = p.length - 1;
+ this.closeBlock(); this.listStack = [];
+ const h = this.dom.push('h' + Math.min(level, 6)); this.lastBlockEl = h;
+ this._bd(); this.atxLevel = level; return;
+ }
+ if (ch !== '#') this.fallbackToParagraph();
+ return;
+
+ case '>': {
+ const { level, i } = this._bqLevel(p);
+ if (i === p.length) return;
+ this.ensureBlockquote(level); this.openParagraph(); this._bd();
+ for (const c of p.slice(i)) this.onInlineChar(c);
+ return;
+ }
+
+ case '`':
+ case '~': {
+ const fc = p[0];
+ if (p.length < 3) return;
+ if (p.split('').every(c => c === fc)) return;
+ const fenceCount = p.length - 1;
+ if (fenceCount >= 3) {
+ this.closeBlock(); this.inCodeFence = true; this.fenceChar = fc;
+ this.fenceCount = fenceCount; this.fencePrefix = ch; this.closingFenceBuf = null;
+ this._bd(); return;
+ }
+ this.fallbackToParagraph(); return;
+ }
+
+ case '|':
+ if (!this.inTable) { this.closeBlock(); this.openTable(); }
+ else this.startTableRow();
+ this._bd(); this.openTableCell(); return;
+
+ case '*': {
+ // Abbreviation definition *[Abbr]:
+ if (p[1] === '[') {
+ const ci = p.indexOf(']:');
+ if (ci > 2) { this.defPending = { type: 'abbr', key: p.slice(2, ci), value: '' }; this._bd(); return; }
+ const bracketIdx = p.indexOf(']', 2);
+ if (bracketIdx === -1) return;
+ if (p.length === bracketIdx + 1) return;
+ this.fallbackToParagraph(); return;
+ }
+ // Unordered list / thematic break
+ if (p.length === 1) return;
+ if (p.length === 2) {
+ if (p[1] === ' ') return this.openUlDecided('');
+ if (p[1] !== '*') return this.fallbackToParagraph();
+ return;
+ }
+ if (p.length === 3) { if (p === '***') return; this.fallbackToParagraph(); return; }
+ if (p.length === 4) {
+ if (p[3] === ' ' || p[3] === '*') return this.startHrWatch('*', p.split('*').length - 1, false);
+ this.fallbackToParagraph(); return;
+ }
+ this.fallbackToParagraph(); return;
+ }
+
+ case '-':
+ if (p.length === 1) return;
+ if (p.length === 2) {
+ if (p[1] === ' ') return;
+ if (this.lastBlockEl?.tagName === 'P') return this.startSetextWatch('-', p, p[1] !== '-');
+ return this.startHrWatch('-', 2, p[1] !== '-');
+ }
+ if (p.length === 3) {
+ if (p === '- -' || p === '- *') return;
+ if (p[1] === ' ' && p[2] !== '-' && p[2] !== ' ') return this.openUlDecided(p[2]);
+ if (p[1] === ' ' && p[2] === ' ') return;
+ return (this.lastBlockEl?.tagName === 'P')
+ ? this.startSetextWatch('-', p, false)
+ : this.startHrWatch('-', p.split('-').length - 1, false);
+ }
+ if (p.length === 4) {
+ if (p === '- - ') return;
+ if (p.startsWith('- -')) return this.startHrWatch('-', 2, false);
+ return this.openUlDecided(p.slice(2));
+ }
+ if (/^(- )+$/.test(p) || /^(- )+-?$/.test(p)) return;
+ if (/^- /.test(p)) return this.openUlDecided(p.slice(2));
+ return this.startHrWatch('-', (p.match(/-/g)||[]).length, false);
+
+ case '+':
+ if (p.length === 1) return;
+ if (p[1] === ' ') { this.openUlDecided(p.slice(2)); return; }
+ this.fallbackToParagraph(); return;
+
+ case '_':
+ if (/^[_ ]+$/.test(p)) return;
+ this.fallbackToParagraph(); return;
+
+ case '=':
+ if (this.lastBlockEl?.tagName === 'P') { this.startSetextWatch('=', p, ch !== '='); return; }
+ this._blockDefault(ch); return;
+
+ case '[': {
+ if (p[1] === '^') {
+ // Footnote definition [^id]:
+ const ci = p.indexOf(']:');
+ if (ci !== -1 && ci >= 3) {
+ const id = p.slice(2, ci);
+ this.closeBlock();
+ if (!this.footnoteDefs[id]) {
+ const span = document.createElement('span');
+ span.dataset.fn = id; span.style.display = 'none';
+ this.root.appendChild(span);
+ this.footnoteDefs[id] = span; this.footnoteOrder.push(id);
+ }
+ this.dom.toRoot(); this.dom.current = this.footnoteDefs[id];
+ this.inFootnoteDef = true; this.footnoteDefId = id;
+ this._bd(); return;
+ }
+ if (!p.includes(']') || p[p.length - 1] === ']') return;
+ this.fallbackToParagraph(); return;
+ }
+ // Reference link definition [label]:
+ if (!p[1]) return;
+ const ci = p.indexOf(']:');
+ if (ci > 1) { this.defPending = { type: 'ref', key: p.slice(1, ci).toLowerCase(), value: '' }; this._bd(); return; }
+ if (!p.includes(']') || p[p.length - 1] === ']') return;
+ this.fallbackToParagraph(); return;
+ }
+
+ case '<': {
+ if (p.length === 1) return;
+ if (p.startsWith('<!--')) {
+ if (p.endsWith('-->')) { this._bd(); return; }
+ if (p.length < 7) return;
+ this.closeBlock(); this.inRawHtml = true; this.rawHtmlBuf = p; this.rawHtmlTag = '!--';
+ this._bd(); return;
+ }
+ if (p.search(/[>\s/]/, 1) === -1) return;
+ const m = p.match(/^<\/?([a-zA-Z][a-zA-Z0-9-]*)/);
+ if (m && BLOCK_TAGS.has(m[1].toLowerCase())) {
+ this.closeBlock(); this.inRawHtml = true; this.rawHtmlBuf = p; this.rawHtmlTag = null;
+ this._bd(); return;
+ }
+ this.fallbackToParagraph(); return;
+ }
+
+ case ':': {
+ if (p.length === 1) return;
+ if (p[1] === ' ' && this.lastBlockEl) {
+ if (this.lastBlockEl.tagName === 'P') { this.convertLastPToDt(); this._bd(); return; }
+ if (this.lastBlockEl.tagName === 'DD') {
+ const dlEl = this.dom.find('DL');
+ if (dlEl) {
+ this.dom.popTo(dlEl);
+ const dd = this.dom.push('dd'); this.lastBlockEl = dd; this.textNode = null;
+ this._bd(); return;
+ }
+ }
+ }
+ this._blockDefault(ch); return;
+ }
+
+ default:
+ // Ordered list
+ if (p[0] >= '1' && p[0] <= '9') {
+ if (p.length === 1) return;
+ if (p.length === 2 && (p[1] === '.' || p[1] === ')')) return;
+ if (p.length === 3 && (p[1] === '.' || p[1] === ')') && p[2] === ' ') {
+ this.openListItem('ol', this.lineIndent); this._bd(); return;
+ }
+ if (p.length >= 2 && p[1] !== '.' && p[1] !== ')') { this.fallbackToParagraph(); return; }
+ return;
+ }
+ this._blockDefault(ch);
+ }
+ }
+
+ // ── Content chars ──────────────────────────────────────────────────────────
+ onContentChar(ch) {
+ if (this.sepWatch && this.inTable) {
+ this.sepBuf += ch;
+ if (ch !== '-' && ch !== ':' && ch !== ' ' && ch !== '|') this.sepFailed = true;
+ }
+ if (this.setextWatch) {
+ if (ch === this.setextChar) this.setextBuf += ch;
+ else { this.setextFailed = true; this.setextBuf += ch; }
+ return;
+ }
+ if (this.hrWatch) {
+ if (ch === this.hrChar) this.hrCount++;
+ else if (ch !== ' ') this.hrFailed = true;
+ return;
+ }
+ if (this.defPending) { this.defPending.value += ch; return; }
+ if (this.inTable && ch === '|') {
+ this.flushInlinePending();
+ if (this.bareUrlOpen) this.closeBareUrl();
+ this.textNode = null;
+ if (this.inCell) { this.dom.pop(); this.inCell = false; }
+ this.tablePipePending = true; this.tableColIndex++;
+ return;
+ }
+ if (this.inTable && this.tablePipePending) { this.tablePipePending = false; this.openTableCell(); }
+ this.onInlineChar(ch);
+ }
+
+ // ── Inline state machine ───────────────────────────────────────────────────
+ onInlineChar(ch) {
+ // Task list checkbox
+ if (this.taskCheckBuf !== null && !this.taskCheckDone) {
+ this.taskCheckBuf += ch;
+ const b = this.taskCheckBuf;
+ if (b.length === 1 && b !== '[') {
+ this._doneTaskCheck(); // fall through
+ } else if (b.length <= 3 && !['[ ','[x','[X','[ ]','[x]','[X]'].some(s => b === s || s.startsWith(b))) {
+ this._doneTaskCheck(); this.writeText(b); return;
+ } else if (b.length === 4) {
+ if (b === '[ ] ' || b === '[x] ' || b === '[X] ') {
+ const li = this.dom.find('LI');
+ if (li) li.classList.add('task-item');
+ const cb = document.createElement('input');
+ cb.type = 'checkbox'; cb.disabled = true; cb.checked = b[1].toLowerCase() === 'x';
+ this.dom.current.appendChild(cb); this.textNode = null;
+ } else { this.writeText(b); }
+ this._doneTaskCheck(); return;
+ } else { return; }
+ // b.length===1 && b!=='[': fall through to normal inline
+ }
+
+ // Inline code
+ if (this.dom.current._mdMarker === '`') {
+ if (ch === '`') { this.dom.pop(); this.textNode = null; }
+ else this.appendToTextNode(ch);
+ return;
+ }
+
+ if (this.linkState !== null) { this.onLinkChar(ch); return; }
+
+ // Backslash escape
+ if (this.escapeNext) { this.escapeNext = false; this.appendToTextNode(ch); this.prevCharWs = false; return; }
+ if (ch === '\\') { this.escapeNext = true; return; }
+
+ // HTML entity
+ if (this.entityBuf !== null) {
+ this.entityBuf += ch;
+ if (ch === ';') { this.writeText(this.decodeEntity(this.entityBuf)); this.entityBuf = null; this.prevCharWs = false; return; }
+ if (this.entityBuf.length > 12) { this.writeText(this.entityBuf); this.entityBuf = null; }
+ return;
+ }
+ if (ch === '&') { this.entityBuf = '&'; return; }
+
+ // Autolink / inline HTML
+ if (this.autolinkBuf !== null) {
+ if (this.autolinkBuf === '!-' && ch === '-') { this.autolinkBuf = '!--'; return; }
+ if (this.autolinkBuf.startsWith('!--')) {
+ this.autolinkBuf += ch;
+ if (this.autolinkBuf.endsWith('-->')) { this.autolinkBuf = null; this.prevCharWs = false; }
+ return;
+ }
+ if (ch === '>') {
+ const buf = this.autolinkBuf;
+ if (buf.startsWith('/') && /^\/\w+$/.test(buf)) {
+ const el = this.dom.find(buf.slice(1).toUpperCase());
+ if (el) this._pop(el);
+ this.textNode = null; this.autolinkBuf = null; this.prevCharWs = false; return;
+ }
+ const tagMatch = buf.match(/^(\w+)(\s|$)/);
+ const knownInline = ['KBD','SPAN','SUP','SUB','ABBR','CITE','CODE','B','I','U','S','MARK','SMALL','DEL','INS','Q','VAR'];
+ if (tagMatch && knownInline.includes(tagMatch[1].toUpperCase())) {
+ this.dom.push(tagMatch[1]); this.textNode = null;
+ this.autolinkBuf = null; this.prevCharWs = false; return;
+ }
+ if (buf.includes('://') || buf.includes('@')) {
+ const a = document.createElement('a');
+ a.href = buf.includes('@') && !buf.startsWith('http') ? 'mailto:' + buf : buf;
+ this.initAnchor(a); a.appendChild(document.createTextNode(buf));
+ this.dom.current.appendChild(a); this.textNode = null;
+ this.autolinkBuf = null; this.prevCharWs = false; return;
+ }
+ this.appendToTextNode('<' + buf + '>');
+ this.autolinkBuf = null; this.prevCharWs = false; return;
+ }
+ if (ch === ' ' || ch === '<') {
+ this.appendToTextNode('<' + this.autolinkBuf); this.autolinkBuf = null;
+ if (ch === '<') this.autolinkBuf = ''; return;
+ }
+ this.autolinkBuf += ch; return;
+ }
+ if (ch === '<') { this.autolinkBuf = ''; return; }
+
+ // Bare URL
+ if (this.bareUrlOpen) {
+ if (ch === ' ' || ch === '\n' || (ch === ')' && !this.bareUrlParens)) {
+ this.closeBareUrl();
+ if (ch === ' ') this.appendToTextNode(ch);
+ } else {
+ if (ch === '(') this.bareUrlParens = (this.bareUrlParens || 0) + 1;
+ if (ch === ')') this.bareUrlParens = Math.max(0, (this.bareUrlParens || 0) - 1);
+ this.appendToTextNode(ch);
+ }
+ this.prevCharWs = (ch === ' '); return;
+ }
+ if (this.bareUrlBuf !== null) {
+ this.bareUrlBuf += ch;
+ const b = this.bareUrlBuf;
+ const prefixes = ['https://', 'http://'];
+ if (!prefixes.some(pfx => pfx.startsWith(b) || b.startsWith(pfx))) {
+ this.writeText(b); this.bareUrlBuf = null; this.prevCharWs = false; return;
+ }
+ if (prefixes.find(pfx => b.startsWith(pfx))) {
+ const a = this.dom.push('a'); this.initAnchor(a);
+ this.textNode = null; this.writeText(b);
+ this.bareUrlBuf = null; this.bareUrlOpen = true; this.bareUrlParens = 0;
+ }
+ return;
+ }
+ if (this.prevCharWs && ch === 'h') { this.bareUrlBuf = 'h'; this.prevCharWs = false; return; }
+
+ // ==highlight==
+ if (ch === '=') {
+ if (this.inlinePending && this.inlinePending[0] === '=') {
+ this.inlinePending += ch;
+ if (this.inlinePending.length > 2) this.flushInlinePending();
+ return;
+ }
+ if (!this.inlinePending) { this.inlinePending = '='; return; }
+ this.resolveInlinePending(ch); return;
+ }
+
+ if (ch === '!') { this.linkState = 'bang'; this.linkIsImage = true; this.prevCharWs = false; return; }
+ if (ch === '[') {
+ const a = this.dom.push('a'); this.initAnchor(a);
+ this.textNode = null;
+ this.linkState = 'label_open'; this.urlBuf = ''; this.linkBuf = '';
+ this.prevCharWs = false; return;
+ }
+
+ if (this.isMarkerChar(ch)) {
+ if (this.inlinePending && ch !== this.inlinePending[0]) this.resolveInlinePending(null);
+ this.inlinePending += ch;
+ this.prevCharWs = false; return;
+ }
+
+ if (this.inlinePending) { this.resolveInlinePending(ch); this.prevCharWs = (ch === ' '); return; }
+ this.appendToTextNode(ch);
+ this.prevCharWs = (ch === ' ');
+ }
+
+ isMarkerChar(ch) { return '`*_~^'.includes(ch); }
+
+ closeBareUrl() {
+ const a = this.dom.find('A');
+ if (a && !a.href) {
+ const text = a.textContent;
+ const m = text.match(/[.,!?)\]>]+$/);
+ if (m) {
+ a.textContent = text.slice(0, -m[0].length);
+ a.href = a.textContent;
+ this._pop(a); this._resetBareUrl();
+ this.writeText(m[0]); return;
+ }
+ a.href = text.trim();
+ this._pop(a);
+ }
+ this._resetBareUrl();
+ }
+ _resetBareUrl() { this.bareUrlOpen = false; this.bareUrlBuf = null; this.bareUrlParens = 0; this.textNode = null; }
+
+ // ── Link / image state machine ─────────────────────────────────────────────
+ onLinkChar(ch) {
+ switch (this.linkState) {
+ case 'bang':
+ if (ch === '[') { this.linkState = 'img_alt'; this.linkBuf = ''; this.urlBuf = ''; }
+ else { this.linkState = null; this.linkIsImage = false; this.appendToTextNode('!'); this.onInlineChar(ch); }
+ return;
+
+ case 'img_alt':
+ if (ch === ']') this.linkState = 'img_expect_paren';
+ else this.linkBuf += ch;
+ return;
+
+ case 'img_expect_paren':
+ if (ch === '(') { this.linkState = 'img_url'; this.urlBuf = ''; }
+ else { this.appendToTextNode('![' + this.linkBuf + ']' + ch); this.linkState = null; this.linkIsImage = false; this.linkBuf = ''; }
+ return;
+
+ case 'img_url':
+ if (ch === ')') {
+ const { url: iUrl, title: iTitle } = this._parseUrlBuf();
+ const img = document.createElement('img');
+ img.src = iUrl; img.alt = this.linkBuf;
+ if (iTitle) img.title = iTitle;
+ const insideLink = !!this.dom.find('A');
+ if (!insideLink) img.className = 'blk';
+ this.dom.current.appendChild(img);
+ this.textNode = null; this.linkBuf = ''; this.urlBuf = ''; this.linkIsImage = false;
+ this.linkState = insideLink ? 'label_open' : null;
+ } else { this.urlBuf += ch; }
+ return;
+
+ case 'label_open':
+ if (ch === '!') { this.linkState = 'bang'; this.linkIsImage = true; return; }
+ if (ch === ']') {
+ this.flushInlinePending();
+ this._popMarkers(); this.textNode = null;
+ const a = this.dom.find('A'); if (a) this.linkBuf = a.textContent;
+ this.linkState = 'expect_paren'; return;
+ }
+ if (ch === '^' && this.linkBuf === '') {
+ this.abortLinkElement(null);
+ this.linkState = 'fn_ref'; this.linkBuf = ''; return;
+ }
+ this.linkState = null; this.onInlineChar(ch); this.linkState = 'label_open';
+ return;
+
+ case 'fn_ref':
+ if (ch === ']') {
+ const id = this.linkBuf;
+ const a = document.createElement('a');
+ a.className = 'fn-ref'; a.dataset.fnid = id; a.href = '#fn-' + id;
+ a.appendChild(document.createTextNode('?'));
+ this.dom.current.appendChild(a); this.textNode = null;
+ this.linkState = null; this.linkBuf = '';
+ } else { this.linkBuf += ch; }
+ return;
+
+ case 'expect_paren':
+ if (ch === '(') { this.linkState = 'url'; this.urlBuf = ''; }
+ else if (ch === '[') { this.linkState = 'ref_id'; this.urlBuf = ''; }
+ else {
+ const a = this.dom.find('A');
+ if (a) { a.dataset.implicitRef = this.linkBuf.toLowerCase(); this._pop(a); }
+ this._resetLinkUrl();
+ if (ch !== '\n') this.onInlineChar(ch);
+ }
+ return;
+
+ case 'ref_id':
+ if (ch === ']') {
+ const refKey = (this.urlBuf.trim() || this.linkBuf.trim()).toLowerCase();
+ const url = this.refDefs[refKey];
+ const a = this.dom.find('A');
+ if (a) {
+ if (url) a.href = url;
+ else { a.href = '#'; a.dataset.refKey = refKey; }
+ this._pop(a);
+ }
+ this._resetLinkUrl();
+ } else { this.urlBuf += ch; }
+ return;
+
+ case 'url':
+ if (ch === ')') {
+ const { url, title } = this._parseUrlBuf();
+ const a = this.dom.find('A');
+ if (a) { a.href = url; if (title) a.title = title; this._pop(a); }
+ this._resetLinkUrl();
+ } else { this.urlBuf += ch; }
+ return;
+ }
+ }
+
+ abortLinkElement(extraCh) {
+ const a = this.dom.find('A');
+ if (a) {
+ const parent = a.parentNode;
+ if (parent) { while (a.firstChild) parent.insertBefore(a.firstChild, a); parent.removeChild(a); }
+ this.dom.current = parent || this.dom.bottomStack;
+ }
+ this._resetLinkUrl();
+ if (extraCh !== null) this.appendToTextNode(extraCh);
+ }
+
+ _parseUrlBuf() {
+ const raw = this.urlBuf.trim();
+ const m = raw.match(/^(.*?)\s+["'](.*?)["']$/);
+ return { url: m ? m[1] : raw, title: m ? m[2] : null };
+ }
+
+ // ── Resolve inline pending ─────────────────────────────────────────────────
+ resolveInlinePending(nextCh) {
+ const marker = this.inlinePending; this.inlinePending = '';
+ if (marker) {
+ if (marker === '***') {
+ const closeEl = this.findInlineClose('***');
+ if (closeEl !== null) {
+ this._pop(closeEl); this.textNode = null;
+ if (this.dom.current._mdMarker === '***_em') { this.dom.pop(); this.textNode = null; }
+ } else {
+ const strong = this.dom.push('strong'); strong._mdMarker = '***'; this.textNode = null;
+ const em = this.dom.push('em'); em._mdMarker = '***_em'; this.textNode = null;
+ }
+ } else {
+ const closeEl = this.findInlineClose(marker);
+ if (closeEl !== null) {
+ this._pop(closeEl); this.textNode = null;
+ } else {
+ const tag = this.markerToTag(marker);
+ if (tag) {
+ if (tag === 'strong+em') {
+ const strong = this.dom.push('strong'); strong._mdMarker = marker;
+ const em = document.createElement('em'); em._mdMarker = '***_em';
+ strong.appendChild(em); this.dom.current = em; this.textNode = null;
+ } else {
+ const el = this.dom.push(tag); el._mdMarker = marker; this.textNode = null;
+ }
+ } else {
+ this.writeText(marker);
+ }
+ }
+ }
+ }
+ if (nextCh !== null) this.appendToTextNode(nextCh);
+ }
+
+ markerToTag(marker) {
+ return {'**':'strong','*':'em','__':'u','_':'em','~~':'s','^':'sup','~':'sub','`':'code','==':'mark'}[marker] || null;
+ }
+
+ findInlineClose(marker) {
+ let el = this.dom.current;
+ while (el) {
+ if (el._mdMarker === marker) return el;
+ if (el === this.dom.bottomStack) break;
+ el = el.parentNode;
+ }
+ return null;
+ }
+
+ flushInlinePending() {
+ if (!this.inlinePending) return;
+ const marker = this.inlinePending; this.inlinePending = '';
+ const closeEl = this.findInlineClose(marker);
+ if (closeEl !== null) { this._pop(closeEl); this.textNode = null; }
+ else this.writeText(marker);
+ }
+
+ // ── Small shared helpers ───────────────────────────────────────────────────
+ makeHr() { this.closeBlock(); this.dom.current.appendChild(document.createElement('hr')); this.lastBlockEl = null; }
+ fallbackToParagraph() { this.closeBlock(); this.openParagraph(); this.blockDecided = true; this.feedPendingAsInline(); }
+ initAnchor(a) { a.target = '_blank'; a.rel = 'noopener noreferrer'; }
+ flushDefPending() { if (!this.defPending) return; const d = this.defPending; if (d.type === 'ref') this.refDefs[d.key] = d.value.trim(); if (d.type === 'abbr') this.abbrMap[d.key] = d.value.trim(); this.defPending = null; }
+ startHrWatch(c,n,f) { this.hrWatch = true; this.hrChar = c; this.hrCount = n; this.hrFailed = f; this._bd(); }
+ startSetextWatch(c,b,f){ this.setextWatch = true; this.setextChar = c; this.setextBuf = b; this.setextFailed = f; this._bd(); }
+ openUlDecided(s) { this.openListItem('ul', this.lineIndent); this._bd(); for (const c of s) this.onInlineChar(c); }
+
+ // ── Entity decoder ─────────────────────────────────────────────────────────
+ decodeEntity(raw) {
+ if (ENTITY_MAP[raw]) return ENTITY_MAP[raw];
+ if (raw.startsWith('&#x')) try { return String.fromCodePoint(parseInt(raw.slice(3,-1), 16)); } catch(e) {}
+ if (raw.startsWith('&#')) try { return String.fromCodePoint(parseInt(raw.slice(2,-1), 10)); } catch(e) {}
+ return raw;
+ }
+
+ // ── Text helpers ───────────────────────────────────────────────────────────
+ writeText(str) {
+ if (!this.textNode) { this.textNode = document.createTextNode(''); this.dom.current.appendChild(this.textNode); }
+ this.textNode.data += str;
+ }
+ appendToTextNode(ch) { this.writeText(ch); }
+ feedPendingAsInline() { const s = this.pending; this.pending = ''; for (const ch of s) this.onInlineChar(ch); }
+
+ // ── Code fence ─────────────────────────────────────────────────────────────
+ onCodeFenceNewline() {
+ if (!this.dom.find('PRE')) {
+ const lang = (this.fencePrefix || '').trim().split(/\s+/)[0];
+ this.fencePrefix = null;
+ const pre = this.dom.push('pre');
+ const code = document.createElement('code');
+ if (lang) code.className = 'language-' + lang;
+ pre.appendChild(code);
+ this.textNode = document.createTextNode(''); code.appendChild(this.textNode);
+ this.closingFenceBuf = ''; return;
+ }
+ if (this.closingFenceBuf === this.fenceChar.repeat(this.fenceCount || 3)) {
+ this.inCodeFence = false; this.closingFenceBuf = null; this.textNode = null;
+ const pre = this.dom.find('PRE'); if (pre) this._pop(pre);
+ this.lastBlockEl = null; this.resetLine(); return;
+ }
+ if (this.textNode) this.textNode.data += '\n';
+ this.closingFenceBuf = '';
+ }
+
+ feedCodeFenceLine(ch) {
+ this.closingFenceBuf += ch;
+ const fc = this.fenceCount || 3;
+ const fence = this.fenceChar.repeat(fc);
+ if (this.closingFenceBuf === fence) return;
+ if (this.closingFenceBuf.startsWith(this.fenceChar) && this.closingFenceBuf.length <= fc) return;
+ if (this.textNode) this.textNode.data += ch;
+ }
+
+ // ── Raw HTML passthrough ───────────────────────────────────────────────────
+ flushRawHtml() {
+ try {
+ const doc = new DOMParser().parseFromString(this.rawHtmlBuf.trim(), 'text/html');
+ const body = doc.body;
+ while (body.firstChild) this.dom.current.appendChild(document.adoptNode(body.firstChild));
+ } catch(e) { this.writeText(this.rawHtmlBuf); }
+ this.inRawHtml = false; this.rawHtmlBuf = ''; this.rawHtmlTag = null; this.resetLine();
+ }
+
+ // ── Block helpers ──────────────────────────────────────────────────────────
+ closeBlock() {
+ this.flushInlinePending();
+ if (this.bareUrlOpen) this.closeBareUrl();
+ this._popMarkers();
+ this.textNode = null;
+ if (this.dom.depth() > 1) this.dom.toRoot();
+ if (this.inTable) { this.inTable = false; this.tableHeadDone = false; this.inCell = false; this.tableColAlign = []; this.tableColIndex = 0; }
+ this.inFootnoteDef = false;
+ this.inIndentCode = false; this.pendingIndentNL = 0;
+ }
+
+ openParagraph() { const p = this.dom.push('p'); this.lastBlockEl = p; this.textNode = null; }
+
+ ensureBlockquote(level) {
+ this.flushInlinePending();
+ this._popMarkers();
+ this.textNode = null;
+ if (this.dom.currentTag() === 'P') this.dom.pop();
+ let depth = 0, _e = this.dom.current;
+ while (_e) { if (_e.tagName === 'BLOCKQUOTE') depth++; if (_e === this.dom.bottomStack) break; _e = _e.parentNode; }
+ if (depth === 0 && this.dom.depth() > 1) this.dom.toRoot();
+ while (depth < level) { this.dom.push('blockquote'); depth++; }
+ while (depth > level) {
+ if (this.dom.currentTag() === 'P') this.dom.pop();
+ if (this.dom.currentTag() === 'BLOCKQUOTE') this.dom.pop();
+ depth--;
+ }
+ }
+
+ convertLastPToDt() {
+ const p = this.lastBlockEl;
+ const prev = p?.previousElementSibling;
+ const dl = (prev?.tagName === 'DL') ? prev : (() => {
+ const newDl = document.createElement('dl');
+ if (p?.parentNode) p.parentNode.insertBefore(newDl, p);
+ else this.dom.current.appendChild(newDl);
+ return newDl;
+ })();
+ const dt = document.createElement('dt');
+ while (p?.firstChild) dt.appendChild(p.firstChild);
+ p?.parentNode?.removeChild(p);
+ dl.appendChild(dt);
+ if (this.dom.current === p) this.dom.current = dl;
+ const dd = this.dom.push('dd'); this.lastBlockEl = dd; this.textNode = null;
+ }
+
+ openListItem(type, indent) {
+ this._popMarkers();
+ this.textNode = null;
+ if (this.listStack.length === 0) {
+ const tag = this.dom.currentTag();
+ if (!['UL','OL','LI'].includes(tag)) this.closeBlock();
+ this.pushNewList(type, indent);
+ } else {
+ const top = this.listStack[this.listStack.length - 1];
+ if (indent > top.indent) {
+ this.pushNewList(type, indent);
+ } else {
+ if (indent < top.indent) {
+ while (this.listStack.length > 1 && this.listStack[this.listStack.length - 1].indent > indent) {
+ this.listStack.pop();
+ if (this.dom.currentTag() === 'LI') this.dom.pop();
+ if (['UL','OL'].includes(this.dom.currentTag())) this.dom.pop();
+ }
+ }
+ if (this.dom.currentTag() === 'LI') this.dom.pop();
+ const now = this.listStack[this.listStack.length - 1];
+ if (now.type !== type) {
+ if (['UL','OL'].includes(this.dom.currentTag())) this.dom.pop();
+ this.listStack.pop(); this.pushNewList(type, indent);
+ }
+ }
+ }
+ const li = this.dom.push('li'); this.lastBlockEl = li;
+ this.taskCheckBuf = ''; this.taskCheckDone = false;
+ }
+
+ pushNewList(type, indent) {
+ const list = this.dom.push(type);
+ this.listStack.push({ el: list, type, indent });
+ }
+
+ // ── Setext ─────────────────────────────────────────────────────────────────
+ resolveSetext(tag) {
+ const p = this.lastBlockEl;
+ if (!p || p.tagName !== 'P') return;
+ const h = document.createElement(tag);
+ while (p.firstChild) h.appendChild(p.firstChild);
+ p.parentNode.replaceChild(h, p);
+ this.dom.replaceAt(p, h);
+ this.lastBlockEl = h; this.textNode = null;
+ }
+ flushSetextAsFallback() { this.closeBlock(); this.openParagraph(); this.writeText(this.setextBuf); }
+ flushHrAsFallback() { this.closeBlock(); this.openParagraph(); this.writeText(this.hrChar.repeat(this.hrCount)); }
+
+ // ── Table ──────────────────────────────────────────────────────────────────
+ openTable() {
+ this.inTable = true; this.tableHeadDone = false; this.tableColAlign = []; this.tableColIndex = 0;
+ this.dom.push('table'); this.dom.push('thead'); this.dom.push('tr');
+ }
+
+ startTableRow() {
+ const tr = this.dom.find('TR');
+ if (tr) this._pop(tr);
+ this.textNode = null; this.inCell = false; this.tableColIndex = 0;
+ const container = this.dom.find(this.tableHeadDone ? 'TBODY' : 'THEAD');
+ if (container) this.dom.popTo(container);
+ this.dom.push('tr');
+ this.sepWatch = true; this.sepFailed = false;
+ this.sepRowEl = this.dom.find('TR'); this.sepBuf = '';
+ }
+
+ openTableCell() {
+ const tag = this.tableHeadDone ? 'td' : 'th';
+ const cell = this.dom.push(tag);
+ const align = this.tableColAlign[this.tableColIndex] || '';
+ if (align) cell.className = 'align-' + align;
+ this.textNode = null; this.inCell = true;
+ }
+
+ applyTableSep(buf) {
+ const cells = buf.replace(/^\||\|$/g, '').split('|');
+ this.tableColAlign = cells.map(c => {
+ c = c.trim();
+ if (c.startsWith(':') && c.endsWith(':')) return 'center';
+ if (c.endsWith(':')) return 'right';
+ if (c.startsWith(':')) return 'left';
+ return '';
+ });
+ const thead = this.dom.find('THEAD');
+ if (thead) thead.querySelectorAll('th').forEach((th, i) => { if (this.tableColAlign[i]) th.className = 'align-' + this.tableColAlign[i]; });
+ }
+
+ // ── Finalize ───────────────────────────────────────────────────────────────
+ finalize() {
+ this.flushDefPending();
+ this.flushInlinePending();
+ if (this.bareUrlOpen) this.closeBareUrl();
+ this.textNode = null;
+
+ this.root.querySelectorAll('a[href="#"]').forEach(a => {
+ const key = a.dataset.refKey || a.textContent.trim().toLowerCase();
+ if (this.refDefs[key]) { a.href = this.refDefs[key]; delete a.dataset.refKey; }
+ });
+
+ this.root.querySelectorAll('a[data-implicit-ref]').forEach(a => {
+ const key = a.dataset.implicitRef;
+ if (this.refDefs[key]) {
+ a.href = this.refDefs[key]; a.removeAttribute('data-implicit-ref');
+ } else {
+ const parent = a.parentNode;
+ if (parent) {
+ parent.insertBefore(document.createTextNode('[' + a.textContent + ']'), a);
+ parent.removeChild(a);
+ }
+ }
+ });
+
+ if (Object.keys(this.abbrMap).length > 0) this.applyAbbrs(this.root);
+ this.renderFootnotes();
+ }
+
+ applyAbbrs(root) {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
+ for (const node of nodes) {
+ if (!node.parentElement) continue;
+ const tag = node.parentElement.tagName;
+ if (['CODE','PRE','A','ABBR','KBD'].includes(tag)) continue;
+ let segments = [{text: node.data, title: null}];
+ for (const [abbr, title] of Object.entries(this.abbrMap)) {
+ const re = new RegExp('(?<![\\w])' + abbr.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + '(?![\\w])', 'g');
+ const next = [];
+ for (const seg of segments) {
+ if (seg.title !== null) { next.push(seg); continue; }
+ let last = 0; re.lastIndex = 0; let m;
+ while ((m = re.exec(seg.text)) !== null) {
+ if (m.index > last) next.push({text: seg.text.slice(last, m.index), title: null});
+ next.push({text: m[0], title});
+ last = m.index + m[0].length;
+ }
+ if (last < seg.text.length) next.push({text: seg.text.slice(last), title: null});
+ else if (last === 0) next.push(seg);
+ }
+ segments = next;
+ }
+ if (segments.length === 1 && segments[0].title === null) continue;
+ const frag = document.createDocumentFragment();
+ for (const seg of segments) {
+ if (seg.title === null) frag.appendChild(document.createTextNode(seg.text));
+ else { const el = document.createElement('abbr'); el.title = seg.title; el.appendChild(document.createTextNode(seg.text)); frag.appendChild(el); }
+ }
+ node.parentNode.replaceChild(frag, node);
+ }
+ }
+
+ renderFootnotes() {
+ if (!this.footnoteOrder.length) return;
+ const idToNum = {}; let num = 1;
+ this.root.querySelectorAll('.fn-ref').forEach(ref => {
+ const id = ref.dataset.fnid;
+ if (!idToNum[id]) idToNum[id] = num++;
+ ref.textContent = '[' + idToNum[id] + ']';
+ ref.href = '#fn-' + id;
+ });
+ const section = document.createElement('div');
+ section.className = 'footnotes';
+ const ol = document.createElement('ol');
+ section.appendChild(ol);
+ for (const id of this.footnoteOrder) {
+ if (!idToNum[id]) continue;
+ const li = document.createElement('li'); li.id = 'fn-' + id;
+ const span = this.footnoteDefs[id];
+ if (span) { while (span.firstChild) li.appendChild(span.firstChild); span.remove(); }
+ ol.appendChild(li);
+ }
+ this.root.appendChild(section);
+ }
+
+ // ── Streaming control ──────────────────────────────────────────────────────
+ run = true; speed = 100; delay = 0; batch = 4;
+
+ setSpeed(s) { this.speed = s; this.delay = Math.max(0, 80 - s * 0.80); this.batch = s > 80 ? 4 : 1; }
+ stop() { this.run = false; }
+
+ async markdownasync(text) {
+ this.run = true;
+ for (let index = 0; index < text.length;) {
+ if (!this.run) return;
+ for (let b = 0; b < this.batch && index < text.length; b++) {
+ if (!this.run) return;
+ this.processChar(text[index++]);
+ }
+ if (this.delay > 0) await new Promise(resolve => setTimeout(resolve, this.delay));
+ }
+ }
+
+ markdown(text) { for (const char of text) this.processChar(char); }
+}
diff --git a/tests/md4start.js b/tests/md4start.js
new file mode 100644
index 0000000..cc9ea91
--- /dev/null
+++ b/tests/md4start.js
@@ -0,0 +1,43 @@
+const sourceEl = document.getElementById('source');
+const outputEl = document.getElementById('output');
+const streamBtn = document.getElementById('streamBtn');
+const stopBtn = document.getElementById('stopBtn');
+const speedSlider = document.getElementById('speedSlider');
+const themeBtn = document.getElementById('themeBtn');
+
+var streamer;
+
+async function startStream()
+{
+ const text = sourceEl.value;
+ outputEl.innerHTML = '';
+ streamer = new MarkdownStreamer(outputEl);
+
+ streamBtn.disabled = true;
+ stopBtn.disabled = false;
+
+ streamer.setSpeed( parseInt(speedSlider.value));
+
+ await streamer.markdownasync(text);
+
+ streamer.finalize();
+ streamBtn.disabled = false;
+ stopBtn.disabled = true;
+}
+
+function stopStream()
+{
+ streamBtn.disabled = false;
+ stopBtn.disabled = true;
+ streamer.stop();
+}
+
+streamBtn.addEventListener('click', startStream);
+stopBtn.addEventListener('click', stopStream);
+
+
+themeBtn.addEventListener('click', () =>
+{
+ const light = outputEl.classList.toggle('light');
+ themeBtn.textContent = light ? 'Dark' : 'Light';
+});