pygpt-net 2.6.44__py3-none-any.whl → 2.6.45__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pygpt_net/data/js/app.js CHANGED
@@ -599,9 +599,19 @@
599
599
 
600
600
  // Custom markup rules for simple tags in text.
601
601
  this.CUSTOM_MARKUP_RULES = Utils.g('CUSTOM_MARKUP_RULES', [
602
- { name: 'cmd', open: '[!cmd]', close: '[/!cmd]', tag: 'div', className: 'cmd', innerMode: 'text' },
603
- { name: 'think', open: '[!think]', close: '[/!think]', tag: 'think', className: '', innerMode: 'text' }
604
- ]);
602
+ { name: 'cmd', open: '[!cmd]', close: '[/!cmd]', tag: 'div', className: 'cmd', innerMode: 'text' },
603
+ { name: 'think_md', open: '[!think]', close: '[/!think]', tag: 'think', className: '', innerMode: 'text' },
604
+ { name: 'think_html', open: '<think>', close: '</think>', tag: 'think', className: '', innerMode: 'text', stream: true },
605
+ { name: 'tool', open: '<tool>', close: '</tool>', tag: 'div', className: 'cmd', innerMode: 'text', stream: true },
606
+
607
+ // Streams+final: convert [!exec]... into fenced python code BEFORE markdown-it
608
+ { name: 'exec_md', open: '[!exec]', close: '[/!exec]', innerMode: 'text', stream: true,
609
+ openReplace: '```python\n', closeReplace: '\n```', phase: 'source' },
610
+
611
+ // Streams+final: convert <execute>...</execute> into fenced python code BEFORE markdown-it
612
+ { name: 'exec_html', open: '<execute>', close: '</execute>', innerMode: 'text', stream: true,
613
+ openReplace: '```python\n', closeReplace: '\n```', phase: 'source' }
614
+ ]);
605
615
  }
606
616
  }
607
617
 
@@ -911,6 +921,20 @@
911
921
  const hint = (cfg && cfg.RAF && cfg.RAF.FLUSH_BUDGET_MS) ? cfg.RAF.FLUSH_BUDGET_MS : 7;
912
922
  this.SCAN_STEP_BUDGET_MS = Math.max(3, Math.min(12, hint));
913
923
  }
924
+ _decodeEntitiesDeep(text, maxPasses = 2) {
925
+ if (!text || text.indexOf('&') === -1) return text || '';
926
+ const ta = Highlighter._decTA || (Highlighter._decTA = document.createElement('textarea'));
927
+ const decodeOnce = (s) => { ta.innerHTML = s; return ta.value; };
928
+ let prev = String(text);
929
+ let cur = decodeOnce(prev);
930
+ let passes = 1;
931
+ while (passes < maxPasses && cur !== prev) {
932
+ prev = cur;
933
+ cur = decodeOnce(prev);
934
+ passes++;
935
+ }
936
+ return cur;
937
+ }
914
938
  // Global switch to skip all highlighting.
915
939
  isDisabled() { return !!this.cfg.HL.DISABLE_ALL; }
916
940
  // Configure hljs once (safe if hljs not present).
@@ -966,43 +990,44 @@
966
990
  }
967
991
  }
968
992
  // Highlight a single code block with safety checks and scroll preservation.
969
- safeHighlight(codeEl, activeCode) {
970
- if (this.isDisabled()) return;
971
- if (!window.hljs || !codeEl || !codeEl.isConnected) return;
972
- if (!codeEl.closest('.msg-box.msg-bot')) return;
973
- if (codeEl.getAttribute('data-highlighted') === 'yes') return;
974
- if (activeCode && codeEl === activeCode.codeEl) return;
993
+ _needsDeepDecode(text) {
994
+ if (!text) return false;
995
+ const s = String(text);
996
+ return (s.indexOf('&amp;') !== -1) || (s.indexOf('&#') !== -1);
997
+ }
975
998
 
976
- // fast-skip final highlight for gigantic blocks using precomputed meta.
977
- try {
978
- const wrap = codeEl.closest('.code-wrapper');
979
- const maxLines = this.cfg.PROFILE_CODE.finalHighlightMaxLines | 0;
980
- const maxChars = this.cfg.PROFILE_CODE.finalHighlightMaxChars | 0;
981
-
982
- // Prefer wrapper meta if available to avoid .textContent on huge nodes.
983
- let lines = NaN, chars = NaN;
984
- if (wrap) {
985
- const nlAttr = wrap.getAttribute('data-code-nl');
986
- const lenAttr = wrap.getAttribute('data-code-len');
987
- if (nlAttr) lines = parseInt(nlAttr, 10);
988
- if (lenAttr) chars = parseInt(lenAttr, 10);
989
- }
999
+ safeHighlight(codeEl, activeCode) {
1000
+ if (this.isDisabled()) return;
1001
+ if (!window.hljs || !codeEl || !codeEl.isConnected) return;
1002
+ if (!codeEl.closest('.msg-box.msg-bot')) return;
1003
+ if (codeEl.getAttribute('data-highlighted') === 'yes') return;
1004
+ if (activeCode && codeEl === activeCode.codeEl) return;
990
1005
 
991
- if ((Number.isFinite(lines) && maxLines > 0 && lines > maxLines) ||
992
- (Number.isFinite(chars) && maxChars > 0 && chars > maxChars)) {
993
- codeEl.classList.add('hljs');
994
- codeEl.setAttribute('data-highlighted', 'yes');
995
- codeEl.dataset.finalHlSkip = '1';
996
- try { this.codeScroll.attachHandlers(codeEl); } catch (_) {}
997
- this.codeScroll.scheduleScroll(codeEl, false, false);
998
- return;
999
- }
1006
+ // fast-skip final highlight for gigantic blocks using precomputed meta.
1007
+ try {
1008
+ const wrap = codeEl.closest('.code-wrapper');
1009
+ const maxLines = this.cfg.PROFILE_CODE.finalHighlightMaxLines | 0;
1010
+ const maxChars = this.cfg.PROFILE_CODE.finalHighlightMaxChars | 0;
1011
+
1012
+ let lines = NaN, chars = NaN;
1013
+ if (wrap) {
1014
+ const nlAttr = wrap.getAttribute('data-code-nl');
1015
+ const lenAttr = wrap.getAttribute('data-code-len');
1016
+ if (nlAttr) lines = parseInt(nlAttr, 10);
1017
+ if (lenAttr) chars = parseInt(lenAttr, 10);
1018
+ }
1019
+
1020
+ if ((Number.isFinite(lines) && maxLines > 0 && lines > maxLines) ||
1021
+ (Number.isFinite(chars) && maxChars > 0 && chars > maxChars)) {
1022
+ // NEW: normalize entities for readability even if we skip final highlight
1023
+ try {
1024
+ const raw = codeEl.textContent || '';
1025
+ if (this._needsDeepDecode(raw)) {
1026
+ const dec = this._decodeEntitiesDeep(raw);
1027
+ if (dec !== raw) codeEl.textContent = dec;
1028
+ }
1029
+ } catch (_) {}
1000
1030
 
1001
- // Fallback to reading actual text only if wrapper meta is missing.
1002
- if (!Number.isFinite(lines) || !Number.isFinite(chars)) {
1003
- const txt0 = codeEl.textContent || '';
1004
- const ln0 = Utils.countNewlines(txt0);
1005
- if ((maxLines > 0 && ln0 > maxLines) || (maxChars > 0 && txt0.length > maxChars)) {
1006
1031
  codeEl.classList.add('hljs');
1007
1032
  codeEl.setAttribute('data-highlighted', 'yes');
1008
1033
  codeEl.dataset.finalHlSkip = '1';
@@ -1010,32 +1035,59 @@
1010
1035
  this.codeScroll.scheduleScroll(codeEl, false, false);
1011
1036
  return;
1012
1037
  }
1013
- }
1014
- } catch (_) { /* safe fallback */ }
1015
1038
 
1016
- const wasNearBottom = this.codeScroll.isNearBottomEl(codeEl, 16);
1017
- const st = this.codeScroll.state(codeEl);
1018
- const shouldAutoScrollAfter = (st.autoFollow === true) || wasNearBottom;
1039
+ // Fallback to reading actual text only if wrapper meta is missing.
1040
+ if (!Number.isFinite(lines) || !Number.isFinite(chars)) {
1041
+ const txt0 = codeEl.textContent || '';
1042
+ const ln0 = Utils.countNewlines(txt0);
1043
+ if ((maxLines > 0 && ln0 > maxLines) || (maxChars > 0 && txt0.length > maxChars)) {
1044
+ // NEW: normalize entities here as well
1045
+ try {
1046
+ if (this._needsDeepDecode(txt0)) {
1047
+ const dec = this._decodeEntitiesDeep(txt0);
1048
+ if (dec !== txt0) codeEl.textContent = dec;
1049
+ }
1050
+ } catch (_) {}
1019
1051
 
1020
- try {
1021
- try { codeEl.classList.remove('hljs'); codeEl.removeAttribute('data-highlighted'); } catch (_) {}
1022
- const txt = codeEl.textContent || '';
1023
- codeEl.textContent = txt; // ensure no stale spans remain
1024
- hljs.highlightElement(codeEl);
1025
- codeEl.setAttribute('data-highlighted', 'yes');
1026
- } catch (_) {
1027
- if (!codeEl.classList.contains('hljs')) codeEl.classList.add('hljs');
1028
- } finally {
1029
- try { this.codeScroll.attachHandlers(codeEl); } catch (_) {}
1030
- const needInitForce = (codeEl.dataset && (codeEl.dataset.csInitBtm === '1' || codeEl.dataset.justFinalized === '1'));
1031
- const mustScroll = shouldAutoScrollAfter || needInitForce;
1032
- if (mustScroll) this.codeScroll.scheduleScroll(codeEl, false, !!needInitForce);
1033
- if (codeEl.dataset) {
1034
- if (codeEl.dataset.csInitBtm === '1') codeEl.dataset.csInitBtm = '0';
1035
- if (codeEl.dataset.justFinalized === '1') codeEl.dataset.justFinalized = '0';
1052
+ codeEl.classList.add('hljs');
1053
+ codeEl.setAttribute('data-highlighted', 'yes');
1054
+ codeEl.dataset.finalHlSkip = '1';
1055
+ try { this.codeScroll.attachHandlers(codeEl); } catch (_) {}
1056
+ this.codeScroll.scheduleScroll(codeEl, false, false);
1057
+ return;
1058
+ }
1059
+ }
1060
+ } catch (_) { /* safe fallback */ }
1061
+
1062
+ const wasNearBottom = this.codeScroll.isNearBottomEl(codeEl, 16);
1063
+ const st = this.codeScroll.state(codeEl);
1064
+ const shouldAutoScrollAfter = (st.autoFollow === true) || wasNearBottom;
1065
+
1066
+ try {
1067
+ try { codeEl.classList.remove('hljs'); codeEl.removeAttribute('data-highlighted'); } catch (_) {}
1068
+
1069
+ // NEW: deep-decode text before highlighting (fixes &amp;#x27; → ' etc.)
1070
+ let txt = codeEl.textContent || '';
1071
+ if (this._needsDeepDecode(txt)) {
1072
+ try { txt = this._decodeEntitiesDeep(txt); } catch (_) {}
1073
+ }
1074
+ codeEl.textContent = txt; // ensure no stale spans remain and normalized text provided
1075
+
1076
+ hljs.highlightElement(codeEl);
1077
+ codeEl.setAttribute('data-highlighted', 'yes');
1078
+ } catch (_) {
1079
+ if (!codeEl.classList.contains('hljs')) codeEl.classList.add('hljs');
1080
+ } finally {
1081
+ try { this.codeScroll.attachHandlers(codeEl); } catch (_) {}
1082
+ const needInitForce = (codeEl.dataset && (codeEl.dataset.csInitBtm === '1' || codeEl.dataset.justFinalized === '1'));
1083
+ const mustScroll = shouldAutoScrollAfter || needInitForce;
1084
+ if (mustScroll) this.codeScroll.scheduleScroll(codeEl, false, !!needInitForce);
1085
+ if (codeEl.dataset) {
1086
+ if (codeEl.dataset.csInitBtm === '1') codeEl.dataset.csInitBtm = '0';
1087
+ if (codeEl.dataset.justFinalized === '1') codeEl.dataset.justFinalized = '0';
1088
+ }
1036
1089
  }
1037
1090
  }
1038
- }
1039
1091
 
1040
1092
  // Start a budgeted global scan – split across frames to avoid long blocking.
1041
1093
  _startGlobalScan(activeCode) {
@@ -1155,912 +1207,1299 @@
1155
1207
  }
1156
1208
  }
1157
1209
 
1210
+ // ==========================================================================
1211
+ // 4) Custom Markup Processor
1212
+ // ==========================================================================
1213
+
1214
+ class CustomMarkup {
1215
+ constructor(cfg, logger) {
1216
+ this.cfg = cfg || { CUSTOM_MARKUP_RULES: [] };
1217
+ this.logger = logger || new Logger(cfg);
1218
+ this.__compiled = null;
1219
+ this.__hasStreamRules = false; // Fast flag to skip stream work if not needed
1220
+ }
1221
+ _d(line, ctx) { try { this.logger.debug('CM', line, ctx); } catch (_) {} }
1222
+
1223
+ // Decode HTML entities once (safe)
1224
+ // This addresses cases when linkify/full markdown path leaves literal "&quot;" etc. in text nodes.
1225
+ // We decode only for rules that explicitly opt-in (see compile()) to avoid changing semantics globally.
1226
+ decodeEntitiesOnce(s) {
1227
+ if (!s || s.indexOf('&') === -1) return String(s || '');
1228
+ const ta = CustomMarkup._decTA || (CustomMarkup._decTA = document.createElement('textarea'));
1229
+ ta.innerHTML = s;
1230
+ return ta.value;
1231
+ }
1232
+
1233
+ // Small helper: escape text to safe HTML (shared Utils or fallback)
1234
+ _escHtml(s) {
1235
+ try { return Utils.escapeHtml(s); } catch (_) {
1236
+ return String(s || '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
1237
+ }
1238
+ }
1239
+
1240
+ // quick check if any rule's open token is present in text (used to skip expensive work early)
1241
+ hasAnyOpenToken(text, rules) {
1242
+ if (!text || !rules || !rules.length) return false;
1243
+ for (let i = 0; i < rules.length; i++) {
1244
+ const r = rules[i];
1245
+ if (!r || !r.open) continue;
1246
+ if (text.indexOf(r.open) !== -1) return true;
1247
+ }
1248
+ return false;
1249
+ }
1250
+
1251
+ // Build inner HTML from text according to rule's mode (markdown-inline | text) with optional entity decode.
1252
+ _materializeInnerHTML(rule, text, MD) {
1253
+ let payload = String(text || '');
1254
+ if (rule && rule.decodeEntities && payload && payload.indexOf('&') !== -1) {
1255
+ try { payload = this.decodeEntitiesOnce(payload); } catch (_) { /* keep original */ }
1256
+ }
1257
+ if (rule && rule.innerMode === 'markdown-inline' && MD && typeof MD.renderInline === 'function') {
1258
+ try { return MD.renderInline(payload); } catch (_) { return this._escHtml(payload); }
1259
+ }
1260
+ return this._escHtml(payload);
1261
+ }
1262
+
1263
+ // Make a DOM Fragment from HTML string (robust across contexts).
1264
+ _fragmentFromHTML(html, ctxNode) {
1265
+ let frag = null;
1266
+ try {
1267
+ const range = document.createRange();
1268
+ const ctx = (ctxNode && ctxNode.parentNode) ? ctxNode.parentNode : (document.body || document.documentElement);
1269
+ range.selectNode(ctx);
1270
+ frag = range.createContextualFragment(String(html || ''));
1271
+ return frag;
1272
+ } catch (_) {
1273
+ const tmp = document.createElement('div');
1274
+ tmp.innerHTML = String(html || '');
1275
+ frag = document.createDocumentFragment();
1276
+ while (tmp.firstChild) frag.appendChild(tmp.firstChild);
1277
+ return frag;
1278
+ }
1279
+ }
1280
+
1281
+ // Replace one element in DOM with HTML string (keeps siblings intact).
1282
+ _replaceElementWithHTML(el, html) {
1283
+ if (!el || !el.parentNode) return;
1284
+ const parent = el.parentNode;
1285
+ const frag = this._fragmentFromHTML(html, el);
1286
+ try {
1287
+ // Insert new nodes before the old element, then remove the old element (widely supported).
1288
+ parent.insertBefore(frag, el);
1289
+ parent.removeChild(el);
1290
+ } catch (_) {
1291
+ // Conservative fallback: wrap in a span if direct fragment insertion failed for some reason.
1292
+ const tmp = document.createElement('span');
1293
+ tmp.innerHTML = String(html || '');
1294
+ while (tmp.firstChild) parent.insertBefore(tmp.firstChild, el);
1295
+ parent.removeChild(el);
1296
+ }
1297
+ }
1298
+
1299
+ // Compile rules once; also precompile strict and whitespace-tolerant "full match" regexes.
1300
+ compile(rules) {
1301
+ const src = Array.isArray(rules) ? rules : (window.CUSTOM_MARKUP_RULES || this.cfg.CUSTOM_MARKUP_RULES || []);
1302
+ const compiled = [];
1303
+ let hasStream = false;
1304
+
1305
+ for (const r of src) {
1306
+ if (!r || typeof r.open !== 'string' || typeof r.close !== 'string') continue;
1307
+
1308
+ const tag = (r.tag || 'span').toLowerCase();
1309
+ const className = (r.className || r.class || '').trim();
1310
+ const innerMode = (r.innerMode === 'markdown-inline' || r.innerMode === 'text') ? r.innerMode : 'text';
1311
+
1312
+ const stream = !!(r.stream === true);
1313
+ const openReplace = String((r.openReplace != null ? r.openReplace : (r.openReplace || '')) || '');
1314
+ const closeReplace = String((r.closeReplace != null ? r.closeReplace : (r.closeReplace || '')) || '');
1315
+
1316
+ // Back-compat: decode entities default true for cmd-like
1317
+ const decodeEntities = (typeof r.decodeEntities === 'boolean')
1318
+ ? r.decodeEntities
1319
+ : ((r.name || '').toLowerCase() === 'cmd' || className === 'cmd');
1320
+
1321
+ // Optional application phase (where replacement should happen)
1322
+ // - 'source' => before markdown-it
1323
+ // - 'html' => after markdown-it (DOM fragment)
1324
+ // - 'both'
1325
+ let phaseRaw = (typeof r.phase === 'string') ? r.phase.toLowerCase() : '';
1326
+ if (phaseRaw !== 'source' && phaseRaw !== 'html' && phaseRaw !== 'both') phaseRaw = '';
1327
+ // Heuristic: if replacement contains fenced code backticks, default to 'source'
1328
+ const looksLikeFence = (openReplace.indexOf('```') !== -1) || (closeReplace.indexOf('```') !== -1);
1329
+ const phase = phaseRaw || (looksLikeFence ? 'source' : 'html');
1330
+
1331
+ const re = new RegExp(Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close), 'g');
1332
+ const reFull = new RegExp('^' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '$');
1333
+ const reFullTrim = new RegExp('^\\s*' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '\\s*$');
1334
+
1335
+ const item = {
1336
+ name: r.name || tag,
1337
+ tag, className, innerMode,
1338
+ open: r.open, close: r.close,
1339
+ decodeEntities,
1340
+ re, reFull, reFullTrim,
1341
+ stream,
1342
+ openReplace, closeReplace,
1343
+ phase, // NEW: where this rule should be applied
1344
+ isSourceFence: looksLikeFence // NEW: hints StreamEngine to treat as custom fence
1345
+ };
1346
+ compiled.push(item);
1347
+ if (stream) hasStream = true;
1348
+ this._d('COMPILE_RULE', { name: item.name, phase: item.phase, stream: item.stream });
1349
+ }
1350
+
1351
+ if (compiled.length === 0) {
1352
+ const open = '[!cmd]', close = '[/!cmd]';
1353
+ const item = {
1354
+ name: 'cmd', tag: 'p', className: 'cmd', innerMode: 'text', open, close,
1355
+ decodeEntities: true,
1356
+ re: new RegExp(Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close), 'g'),
1357
+ reFull: new RegExp('^' + Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close) + '$'),
1358
+ reFullTrim: new RegExp('^\\s*' + Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close) + '\\s*$'),
1359
+ stream: false,
1360
+ openReplace: '', closeReplace: '',
1361
+ phase: 'html', isSourceFence: false
1362
+ };
1363
+ compiled.push(item);
1364
+ this._d('COMPILE_RULE_FALLBACK', { name: item.name });
1365
+ }
1366
+
1367
+ this.__hasStreamRules = hasStream;
1368
+ return compiled;
1369
+ }
1370
+
1371
+ // pre-markdown source transformer – applies only rules for 'source'/'both' with replacements
1372
+ // IMPORTANT CHANGE:
1373
+ // - Skips replacements inside fenced code blocks (``` / ~~~).
1374
+ // - Applies only when the rule opener is at top-level of the line (no list markers/blockquote).
1375
+ transformSource(src, opts) {
1376
+ let s = String(src || '');
1377
+ this.ensureCompiled();
1378
+ const rules = this.__compiled;
1379
+ if (!rules || !rules.length) return s;
1380
+
1381
+ // Pick only source-phase rules with explicit replacements
1382
+ const candidates = [];
1383
+ for (let i = 0; i < rules.length; i++) {
1384
+ const r = rules[i];
1385
+ if (!r) continue;
1386
+ if ((r.phase === 'source' || r.phase === 'both') && (r.openReplace || r.closeReplace)) candidates.push(r);
1387
+ }
1388
+ if (!candidates.length) return s;
1389
+
1390
+ // Compute fenced-code ranges once to exclude them from replacements (production-safe).
1391
+ const fences = this._findFenceRanges(s);
1392
+ if (!fences.length) {
1393
+ // No code fences in source; apply top-level guarded replacements globally.
1394
+ return this._applySourceReplacementsInChunk(s, s, 0, candidates);
1395
+ }
1396
+
1397
+ // Apply replacements only in segments outside fenced code.
1398
+ let out = '';
1399
+ let last = 0;
1400
+ for (let k = 0; k < fences.length; k++) {
1401
+ const [a, b] = fences[k];
1402
+ if (a > last) {
1403
+ const chunk = s.slice(last, a);
1404
+ out += this._applySourceReplacementsInChunk(s, chunk, last, candidates);
1405
+ }
1406
+ out += s.slice(a, b); // pass fenced code verbatim
1407
+ last = b;
1408
+ }
1409
+ if (last < s.length) {
1410
+ const tail = s.slice(last);
1411
+ out += this._applySourceReplacementsInChunk(s, tail, last, candidates);
1412
+ }
1413
+ return out;
1414
+ }
1415
+
1416
+ // expose custom fence specs (to StreamEngine)
1417
+ getSourceFenceSpecs() {
1418
+ this.ensureCompiled();
1419
+ const rules = this.__compiled || [];
1420
+ const out = [];
1421
+ for (let i = 0; i < rules.length; i++) {
1422
+ const r = rules[i];
1423
+ if (!r || !r.isSourceFence) continue;
1424
+ // Only expose when they actually look like fences in source phase
1425
+ if (r.phase !== 'source' && r.phase !== 'both') continue;
1426
+ out.push({ open: r.open, close: r.close });
1427
+ }
1428
+ return out;
1429
+ }
1430
+
1431
+ // Ensure rules are compiled and cached.
1432
+ ensureCompiled() {
1433
+ if (!this.__compiled) {
1434
+ this.__compiled = this.compile(window.CUSTOM_MARKUP_RULES || this.cfg.CUSTOM_MARKUP_RULES);
1435
+ this._d('ENSURE_COMPILED', { count: this.__compiled.length, hasStream: this.__hasStreamRules });
1436
+ }
1437
+ return this.__compiled;
1438
+ }
1439
+
1440
+ // Replace rules set (also exposes rules on window).
1441
+ setRules(rules) {
1442
+ this.__compiled = this.compile(rules);
1443
+ window.CUSTOM_MARKUP_RULES = Array.isArray(rules) ? rules.slice() : (this.cfg.CUSTOM_MARKUP_RULES || []).slice();
1444
+ this._d('SET_RULES', { count: this.__compiled.length, hasStream: this.__hasStreamRules });
1445
+ }
1446
+
1447
+ // Return current rules as array.
1448
+ getRules() {
1449
+ const list = (window.CUSTOM_MARKUP_RULES ? window.CUSTOM_MARKUP_RULES.slice()
1450
+ : (this.cfg.CUSTOM_MARKUP_RULES || []).slice());
1451
+ this._d('GET_RULES', { count: list.length });
1452
+ return list;
1453
+ }
1454
+
1455
+ // Fast switch: do we have any rules that want streaming parsing?
1456
+ hasStreamRules() {
1457
+ this.ensureCompiled();
1458
+ return !!this.__hasStreamRules;
1459
+ }
1460
+
1461
+ // Context guards
1462
+ isInsideForbiddenContext(node) {
1463
+ const p = node.parentElement; if (!p) return true;
1464
+ // IMPORTANT: exclude code/math/hljs/wrappers AND list contexts (ul/ol/li/dl/dt/dd)
1465
+ return !!p.closest('pre, code, kbd, samp, var, script, style, textarea, .math-pending, .hljs, .code-wrapper, ul, ol, li, dl, dt, dd');
1466
+ }
1467
+ isInsideForbiddenElement(el) {
1468
+ if (!el) return true;
1469
+ // IMPORTANT: exclude code/math/hljs/wrappers AND list contexts (ul/ol/li/dl/dt/dd)
1470
+ return !!el.closest('pre, code, kbd, samp, var, script, style, textarea, .math-pending, .hljs, .code-wrapper, ul, ol, li, dl, dt, dd');
1471
+ }
1472
+
1473
+ // Global finder on a single text blob (original per-text-node logic).
1474
+ findNextMatch(text, from, rules) {
1475
+ let best = null;
1476
+ for (const rule of rules) {
1477
+ rule.re.lastIndex = from;
1478
+ const m = rule.re.exec(text);
1479
+ if (m) {
1480
+ const start = m.index, end = rule.re.lastIndex;
1481
+ if (!best || start < best.start) best = { rule, start, end, inner: m[1] || '' };
1482
+ }
1483
+ }
1484
+ return best;
1485
+ }
1486
+
1487
+ // Strict full match of a pure text node (legacy path).
1488
+ findFullMatch(text, rules) {
1489
+ for (const rule of rules) {
1490
+ if (rule.reFull) {
1491
+ const m = rule.reFull.exec(text);
1492
+ if (m) return { rule, inner: m[1] || '' };
1493
+ } else {
1494
+ rule.re.lastIndex = 0;
1495
+ const m = rule.re.exec(text);
1496
+ if (m && m.index === 0 && (rule.re.lastIndex === text.length)) {
1497
+ const m2 = rule.re.exec(text);
1498
+ if (!m2) return { rule, inner: m[1] || '' };
1499
+ }
1500
+ }
1501
+ }
1502
+ return null;
1503
+ }
1504
+
1505
+ // Set inner content according to the rule's mode, with optional entity decode (element mode).
1506
+ setInnerByMode(el, mode, text, MD, decodeEntities = false) {
1507
+ let payload = String(text || '');
1508
+ if (decodeEntities && payload && payload.indexOf('&') !== -1) {
1509
+ try { payload = this.decodeEntitiesOnce(payload); } catch (_) {}
1510
+ }
1511
+
1512
+ if (mode === 'markdown-inline' && typeof window.markdownit !== 'undefined') {
1513
+ try {
1514
+ if (MD && typeof MD.renderInline === 'function') { el.innerHTML = MD.renderInline(payload); return; }
1515
+ const tempMD = window.markdownit({ html: false, linkify: true, breaks: true, highlight: () => '' });
1516
+ el.innerHTML = tempMD.renderInline(payload); return;
1517
+ } catch (_) {}
1518
+ }
1519
+ el.textContent = payload;
1520
+ }
1521
+
1522
+ // Try to replace an entire <p> that is a full custom markup match.
1523
+ _tryReplaceFullParagraph(el, rules, MD) {
1524
+ if (!el || el.tagName !== 'P') return false;
1525
+ if (this.isInsideForbiddenElement(el)) {
1526
+ this._d('P_SKIP_FORBIDDEN', { tag: el.tagName });
1527
+ return false;
1528
+ }
1529
+ const t = el.textContent || '';
1530
+ if (!this.hasAnyOpenToken(t, rules)) return false;
1531
+
1532
+ for (const rule of rules) {
1533
+ if (!rule) continue;
1534
+ const m = rule.reFullTrim ? rule.reFullTrim.exec(t) : null;
1535
+ if (!m) continue;
1536
+
1537
+ const innerText = m[1] || '';
1538
+
1539
+ if (rule.phase !== 'html' && rule.phase !== 'both') continue; // element materialization is html-phase only
1540
+
1541
+ if (rule.openReplace || rule.closeReplace) {
1542
+ const innerHTML = this._materializeInnerHTML(rule, innerText, MD);
1543
+ const html = String(rule.openReplace || '') + innerHTML + String(rule.closeReplace || '');
1544
+ this._replaceElementWithHTML(el, html);
1545
+ this._d('P_REPLACED_AS_HTML', { rule: rule.name });
1546
+ return true;
1547
+ }
1548
+
1549
+ const outTag = (rule.tag && typeof rule.tag === 'string') ? rule.tag.toLowerCase() : 'span';
1550
+ const out = document.createElement(outTag === 'p' ? 'p' : outTag);
1551
+ if (rule.className) out.className = rule.className;
1552
+ out.setAttribute('data-cm', rule.name);
1553
+ this.setInnerByMode(out, rule.innerMode, innerText, MD, !!rule.decodeEntities);
1554
+
1555
+ try { el.replaceWith(out); } catch (_) {
1556
+ const par = el.parentNode; if (par) par.replaceChild(out, el);
1557
+ }
1558
+ this._d('P_REPLACED', { rule: rule.name, asTag: outTag });
1559
+ return true;
1560
+ }
1561
+ this._d('P_NO_FULL_MATCH', { preview: this.logger.pv(t, 160) });
1562
+ return false;
1563
+ }
1564
+
1565
+ // Core implementation shared by static and streaming passes.
1566
+ applyRules(root, MD, rules) {
1567
+ if (!root || !rules || !rules.length) return;
1568
+
1569
+ const scope = (root.nodeType === 1 || root.nodeType === 11) ? root : document;
1570
+
1571
+ // Phase 1: tolerant <p> replacements
1572
+ try {
1573
+ const paragraphs = (typeof scope.querySelectorAll === 'function') ? scope.querySelectorAll('p') : [];
1574
+ this._d('P_TOLERANT_SCAN_START', { count: paragraphs.length });
1575
+
1576
+ if (paragraphs && paragraphs.length) {
1577
+ for (let i = 0; i < paragraphs.length; i++) {
1578
+ const p = paragraphs[i];
1579
+ if (p && p.getAttribute && p.getAttribute('data-cm')) continue;
1580
+ const tc = p && (p.textContent || '');
1581
+ if (!tc || !this.hasAnyOpenToken(tc, rules)) continue;
1582
+ // Skip paragraphs inside forbidden contexts (includes lists now)
1583
+ if (this.isInsideForbiddenElement(p)) continue;
1584
+ this._tryReplaceFullParagraph(p, rules, MD);
1585
+ }
1586
+ }
1587
+ } catch (e) {
1588
+ this._d('P_TOLERANT_SCAN_ERR', String(e));
1589
+ }
1590
+
1591
+ // Phase 2: legacy per-text-node pass for partial inline cases.
1592
+ const self = this;
1593
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
1594
+ acceptNode: (node) => {
1595
+ const val = node && node.nodeValue ? node.nodeValue : '';
1596
+ if (!val || !self.hasAnyOpenToken(val, rules)) return NodeFilter.FILTER_SKIP;
1597
+ if (self.isInsideForbiddenContext(node)) return NodeFilter.FILTER_REJECT;
1598
+ return NodeFilter.FILTER_ACCEPT;
1599
+ }
1600
+ });
1601
+
1602
+ let node;
1603
+ while ((node = walker.nextNode())) {
1604
+ const text = node.nodeValue;
1605
+ if (!text || !this.hasAnyOpenToken(text, rules)) continue; // quick skip
1606
+ const parent = node.parentElement;
1607
+
1608
+ // Entire text node equals one full match and parent is <p>.
1609
+ if (parent && parent.tagName === 'P' && parent.childNodes.length === 1) {
1610
+ const fm = this.findFullMatch(text, rules);
1611
+ if (fm) {
1612
+ // If explicit HTML replacements are provided, swap <p> for exact HTML (only for html/both phase).
1613
+ if ((fm.rule.phase === 'html' || fm.rule.phase === 'both') && (fm.rule.openReplace || fm.rule.closeReplace)) {
1614
+ const innerHTML = this._materializeInnerHTML(fm.rule, fm.inner, MD);
1615
+ const html = String(fm.rule.openReplace || '') + innerHTML + String(fm.rule.closeReplace || '');
1616
+ this._replaceElementWithHTML(parent, html);
1617
+ this._d('WALKER_FULL_REPLACE_HTML', { rule: fm.rule.name, preview: this.logger.pv(text, 160) });
1618
+ continue;
1619
+ }
1620
+
1621
+ // Backward-compatible: only replace as <p> when rule tag is 'p'
1622
+ if (fm.rule.tag === 'p') {
1623
+ const out = document.createElement('p');
1624
+ if (fm.rule.className) out.className = fm.rule.className;
1625
+ out.setAttribute('data-cm', fm.rule.name);
1626
+ this.setInnerByMode(out, fm.rule.innerMode, fm.inner, MD, !!fm.rule.decodeEntities);
1627
+ try { parent.replaceWith(out); } catch (_) {
1628
+ const par = parent.parentNode; if (par) par.replaceChild(out, parent);
1629
+ }
1630
+ this._d('WALKER_FULL_REPLACE', { rule: fm.rule.name, preview: this.logger.pv(text, 160) });
1631
+ continue;
1632
+ }
1633
+ }
1634
+ }
1635
+
1636
+ // General inline replacement inside the text node (span-like or HTML-replace).
1637
+ let i = 0;
1638
+ let didReplace = false;
1639
+ const frag = document.createDocumentFragment();
1640
+
1641
+ while (i < text.length) {
1642
+ const m = this.findNextMatch(text, i, rules);
1643
+ if (!m) break;
1644
+
1645
+ if (m.start > i) {
1646
+ frag.appendChild(document.createTextNode(text.slice(i, m.start)));
1647
+ }
1648
+
1649
+ // If HTML replacements are provided, build exact HTML around processed inner – only for html/both phase.
1650
+ if ((m.rule.openReplace || m.rule.closeReplace) && (m.rule.phase === 'html' || m.rule.phase === 'both')) {
1651
+ const innerHTML = this._materializeInnerHTML(m.rule, m.inner, MD);
1652
+ const html = String(m.rule.openReplace || '') + innerHTML + String(m.rule.closeReplace || '');
1653
+ const part = this._fragmentFromHTML(html, node);
1654
+ frag.appendChild(part);
1655
+ this._d('WALKER_INLINE_MATCH_HTML', { rule: m.rule.name, start: m.start, end: m.end });
1656
+ i = m.end; didReplace = true; continue;
1657
+ }
1658
+
1659
+ // If rule is not html-phase, do NOT inject open/close replacements here (source-only rules are handled pre-md).
1660
+ if (m.rule.openReplace || m.rule.closeReplace) {
1661
+ // Source-only replacement met in DOM pass – keep original text verbatim for this match.
1662
+ frag.appendChild(document.createTextNode(text.slice(m.start, m.end)));
1663
+ this._d('WALKER_INLINE_SKIP_SOURCE_PHASE_HTML', { rule: m.rule.name, start: m.start, end: m.end });
1664
+ i = m.end; didReplace = true; continue;
1665
+ }
1666
+
1667
+ // Element-based inline replacement (original behavior).
1668
+ const tag = (m.rule.tag === 'p') ? 'span' : m.rule.tag;
1669
+ const el = document.createElement(tag);
1670
+ if (m.rule.className) el.className = m.rule.className;
1671
+ el.setAttribute('data-cm', m.rule.name);
1672
+ this.setInnerByMode(el, m.rule.innerMode, m.inner, MD, !!m.rule.decodeEntities);
1673
+ frag.appendChild(el);
1674
+ this._d('WALKER_INLINE_MATCH', { rule: m.rule.name, start: m.start, end: m.end });
1675
+
1676
+ i = m.end;
1677
+ didReplace = true;
1678
+ }
1679
+
1680
+ if (!didReplace) continue;
1681
+
1682
+ if (i < text.length) {
1683
+ frag.appendChild(document.createTextNode(text.slice(i)));
1684
+ }
1685
+
1686
+ const parentNode = node.parentNode;
1687
+ if (parentNode) {
1688
+ parentNode.replaceChild(frag, node);
1689
+ this._d('WALKER_INLINE_DONE', { preview: this.logger.pv(text, 120) });
1690
+ }
1691
+ }
1692
+ }
1693
+
1694
+ // Public API: apply custom markup for full (static) paths – unchanged behavior.
1695
+ apply(root, MD) {
1696
+ this.ensureCompiled();
1697
+ this.applyRules(root, MD, this.__compiled);
1698
+ }
1699
+
1700
+ // Public API: apply only stream-enabled rules (used in snapshots).
1701
+ applyStream(root, MD) {
1702
+ this.ensureCompiled();
1703
+ if (!this.__hasStreamRules) return;
1704
+ const rules = this.__compiled.filter(r => !!r.stream);
1705
+ if (!rules.length) return;
1706
+ this.applyRules(root, MD, rules);
1707
+ }
1708
+
1709
+ // -----------------------------
1710
+ // INTERNAL HELPERS (NEW)
1711
+ // -----------------------------
1712
+
1713
+ // Scan source and return ranges [start, end) of fenced code blocks (``` or ~~~).
1714
+ // Matches Markdown fences at line-start with up to 3 spaces/tabs indentation.
1715
+ _findFenceRanges(s) {
1716
+ const ranges = [];
1717
+ const n = s.length;
1718
+ let i = 0;
1719
+ let inFence = false;
1720
+ let fenceMark = '';
1721
+ let fenceLen = 0;
1722
+ let startLineStart = 0;
1723
+
1724
+ while (i < n) {
1725
+ const lineStart = i;
1726
+ // Find line end and newline length
1727
+ let j = lineStart;
1728
+ while (j < n && s.charCodeAt(j) !== 10 && s.charCodeAt(j) !== 13) j++;
1729
+ const lineEnd = j;
1730
+ let nl = 0;
1731
+ if (j < n) {
1732
+ if (s.charCodeAt(j) === 13 && j + 1 < n && s.charCodeAt(j + 1) === 10) nl = 2;
1733
+ else nl = 1;
1734
+ }
1735
+
1736
+ // Compute indentation up to 3 "spaces" (tabs count as 1 here – safe heuristic)
1737
+ let k = lineStart;
1738
+ let indent = 0;
1739
+ while (k < lineEnd) {
1740
+ const c = s.charCodeAt(k);
1741
+ if (c === 32 /* space */) { indent++; if (indent > 3) break; k++; }
1742
+ else if (c === 9 /* tab */) { indent++; if (indent > 3) break; k++; }
1743
+ else break;
1744
+ }
1745
+
1746
+ if (!inFence) {
1747
+ if (indent <= 3 && k < lineEnd) {
1748
+ const ch = s.charCodeAt(k);
1749
+ if (ch === 0x60 /* ` */ || ch === 0x7E /* ~ */) {
1750
+ const mark = String.fromCharCode(ch);
1751
+ let m = k;
1752
+ while (m < lineEnd && s.charCodeAt(m) === ch) m++;
1753
+ const run = m - k;
1754
+ if (run >= 3) {
1755
+ inFence = true;
1756
+ fenceMark = mark;
1757
+ fenceLen = run;
1758
+ startLineStart = lineStart;
1759
+ }
1760
+ }
1761
+ }
1762
+ } else {
1763
+ if (indent <= 3 && k < lineEnd && s.charCodeAt(k) === fenceMark.charCodeAt(0)) {
1764
+ let m = k;
1765
+ while (m < lineEnd && s.charCodeAt(m) === fenceMark.charCodeAt(0)) m++;
1766
+ const run = m - k;
1767
+ if (run >= fenceLen) {
1768
+ // Only whitespace is allowed after closing fence on the same line
1769
+ let onlyWS = true;
1770
+ for (let t = m; t < lineEnd; t++) {
1771
+ const cc = s.charCodeAt(t);
1772
+ if (cc !== 32 && cc !== 9) { onlyWS = false; break; }
1773
+ }
1774
+ if (onlyWS) {
1775
+ const endIdx = lineEnd + nl; // include trailing newline if present
1776
+ ranges.push([startLineStart, endIdx]);
1777
+ inFence = false; fenceMark = ''; fenceLen = 0; startLineStart = 0;
1778
+ }
1779
+ }
1780
+ }
1781
+ }
1782
+ i = lineEnd + nl;
1783
+ }
1784
+
1785
+ // If EOF while still in fence, mark until end of string.
1786
+ if (inFence) ranges.push([startLineStart, n]);
1787
+ return ranges;
1788
+ }
1789
+
1790
+ // Check if match starts at "top-level" of a line:
1791
+ // - up to 3 leading spaces/tabs allowed
1792
+ // - not a list item marker ("- ", "+ ", "* ", "1. ", "1) ") and not a blockquote ("> ")
1793
+ // - nothing else precedes the token on the same line
1794
+ _isTopLevelLineInSource(s, absIdx) {
1795
+ let ls = absIdx;
1796
+ while (ls > 0) {
1797
+ const ch = s.charCodeAt(ls - 1);
1798
+ if (ch === 10 /* \n */ || ch === 13 /* \r */) break;
1799
+ ls--;
1800
+ }
1801
+ const prefix = s.slice(ls, absIdx);
1802
+
1803
+ // Strip up to 3 leading "spaces" (tabs treated as 1 – acceptable heuristic)
1804
+ let i = 0, indent = 0;
1805
+ while (i < prefix.length) {
1806
+ const c = prefix.charCodeAt(i);
1807
+ if (c === 32) { indent++; if (indent > 3) break; i++; }
1808
+ else if (c === 9) { indent++; if (indent > 3) break; i++; }
1809
+ else break;
1810
+ }
1811
+ if (indent > 3) return false;
1812
+ const rest = prefix.slice(i);
1813
+
1814
+ // Reject lists/blockquote
1815
+ if (/^>\s?/.test(rest)) return false;
1816
+ if (/^[-+*]\s/.test(rest)) return false;
1817
+ if (/^\d+[.)]\s/.test(rest)) return false;
1818
+
1819
+ // If any other non-whitespace text precedes the token on this line – not top-level
1820
+ if (rest.trim().length > 0) return false;
1821
+
1822
+ return true;
1823
+ }
1824
+
1825
+ // Apply source-phase replacements to one outside-of-fence chunk with top-level guard.
1826
+ _applySourceReplacementsInChunk(full, chunk, baseOffset, rules) {
1827
+ let t = chunk;
1828
+ for (let i = 0; i < rules.length; i++) {
1829
+ const r = rules[i];
1830
+ if (!r || !(r.openReplace || r.closeReplace)) continue;
1831
+ try {
1832
+ r.re.lastIndex = 0;
1833
+ t = t.replace(r.re, (match, inner, offset /*, ...rest*/) => {
1834
+ const abs = baseOffset + (offset | 0);
1835
+ // Only apply when opener is at top-level on that line (not in lists/blockquote)
1836
+ if (!this._isTopLevelLineInSource(full, abs)) return match;
1837
+ const open = r.openReplace || '';
1838
+ const close = r.closeReplace || '';
1839
+ return open + (inner || '') + close;
1840
+ });
1841
+ } catch (_) { /* keep chunk as is on any error */ }
1842
+ }
1843
+ return t;
1844
+ }
1845
+ }
1846
+
1158
1847
  // ==========================================================================
1159
- // 4) Custom Markup Processor
1848
+ // 5) Markdown runtime (markdown-it + code wrapper + math placeholders)
1160
1849
  // ==========================================================================
1161
1850
 
1162
- class CustomMarkup {
1163
- constructor(cfg, logger) {
1164
- this.cfg = cfg || { CUSTOM_MARKUP_RULES: [] };
1165
- this.logger = logger || new Logger(cfg);
1166
- this.__compiled = null;
1167
- }
1168
- _d(line, ctx) { try { this.logger.debug('CM', line, ctx); } catch (_) {} }
1169
-
1170
- // Decode HTML entities once (safe)
1171
- // This addresses cases when linkify/full markdown path leaves literal "&quot;" etc. in text nodes.
1172
- // We decode only for rules that explicitly opt-in (see compile()) to avoid changing semantics globally.
1173
- decodeEntitiesOnce(s) {
1174
- if (!s || s.indexOf('&') === -1) return String(s || '');
1175
- // Using a shared <textarea> avoids DOM parsing side-effects and is fast enough for small JSON payloads.
1176
- const ta = CustomMarkup._decTA || (CustomMarkup._decTA = document.createElement('textarea'));
1177
- ta.innerHTML = s;
1178
- return ta.value;
1179
- }
1180
-
1181
- // Compile rules once; also precompile strict and whitespace-tolerant "full match" regexes.
1182
- compile(rules) {
1183
- const src = Array.isArray(rules) ? rules : (window.CUSTOM_MARKUP_RULES || this.cfg.CUSTOM_MARKUP_RULES || []);
1184
- const compiled = [];
1185
- for (const r of src) {
1186
- if (!r || typeof r.open !== 'string' || typeof r.close !== 'string') continue;
1187
- const tag = (r.tag || 'span').toLowerCase();
1188
- const className = (r.className || r.class || '').trim();
1189
- const innerMode = (r.innerMode === 'markdown-inline' || r.innerMode === 'text') ? r.innerMode : 'text';
1190
-
1191
- // Opt-in decoding: default to true for "cmd" to fix JSON quotes (&quot;) in static full-render path,
1192
- // leave false for other tags unless explicitly requested by rule author.
1193
- const decodeEntities = (typeof r.decodeEntities === 'boolean')
1194
- ? r.decodeEntities
1195
- : ((r.name || '').toLowerCase() === 'cmd' || className === 'cmd');
1196
-
1197
- const re = new RegExp(Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close), 'g');
1198
- const reFull = new RegExp('^' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '$');
1199
- const reFullTrim = new RegExp('^\\s*' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '\\s*$');
1200
-
1201
- const item = {
1202
- name: r.name || tag,
1203
- tag,
1204
- className,
1205
- innerMode,
1206
- open: r.open,
1207
- close: r.close,
1208
- decodeEntities, // per-rule decode switch
1209
- re, reFull, reFullTrim
1210
- };
1211
- compiled.push(item);
1212
- this._d('COMPILE_RULE', { name: item.name, tag: item.tag, innerMode: item.innerMode, open: item.open, close: item.close });
1213
- }
1214
- if (compiled.length === 0) {
1215
- const open = '[!cmd]', close = '[/!cmd]';
1216
- const item = {
1217
- name: 'cmd', tag: 'p', className: 'cmd', innerMode: 'text', open, close,
1218
- decodeEntities: true, // Fallback rule for cmd also opts-in to decoding
1219
- re: new RegExp(Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close), 'g'),
1220
- reFull: new RegExp('^' + Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close) + '$'),
1221
- reFullTrim: new RegExp('^\\s*' + Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close) + '\\s*$')
1851
+ class MarkdownRenderer {
1852
+ constructor(cfg, customMarkup, logger, asyncer, raf) {
1853
+ this.cfg = cfg; this.customMarkup = customMarkup; this.MD = null;
1854
+ this.logger = logger || new Logger(cfg);
1855
+ // Cooperative async utilities available in renderer for heavy decode/render paths
1856
+ this.asyncer = asyncer || new AsyncRunner(cfg, raf);
1857
+ this.raf = raf || null;
1858
+
1859
+ // Fast-path streaming renderer without linkify to reduce regex work on hot path.
1860
+ this.MD_STREAM = null;
1861
+
1862
+ this.hooks = {
1863
+ observeNewCode: () => {},
1864
+ observeMsgBoxes: () => {},
1865
+ scheduleMathRender: () => {},
1866
+ codeScrollInit: () => {}
1222
1867
  };
1223
- compiled.push(item);
1224
- this._d('COMPILE_RULE_FALLBACK', { name: item.name });
1225
- }
1226
- return compiled;
1227
- }
1228
- // Ensure rules are compiled and cached.
1229
- ensureCompiled() {
1230
- if (!this.__compiled) {
1231
- this.__compiled = this.compile(window.CUSTOM_MARKUP_RULES || this.cfg.CUSTOM_MARKUP_RULES);
1232
- this._d('ENSURE_COMPILED', { count: this.__compiled.length });
1233
- }
1234
- return this.__compiled;
1235
- }
1236
- // Replace rules set (also exposes rules on window).
1237
- setRules(rules) {
1238
- this.__compiled = this.compile(rules);
1239
- window.CUSTOM_MARKUP_RULES = Array.isArray(rules) ? rules.slice() : (this.cfg.CUSTOM_MARKUP_RULES || []).slice();
1240
- this._d('SET_RULES', { count: this.__compiled.length });
1241
- }
1242
- // Return current rules as array.
1243
- getRules() {
1244
- const list = (window.CUSTOM_MARKUP_RULES ? window.CUSTOM_MARKUP_RULES.slice()
1245
- : (this.cfg.CUSTOM_MARKUP_RULES || []).slice());
1246
- this._d('GET_RULES', { count: list.length });
1247
- return list;
1248
- }
1249
-
1250
- // Context guards
1251
- isInsideForbiddenContext(node) {
1252
- const p = node.parentElement; if (!p) return true;
1253
- return !!p.closest('pre, code, kbd, samp, var, script, style, textarea, .math-pending, .hljs, .code-wrapper');
1254
- }
1255
- isInsideForbiddenElement(el) {
1256
- if (!el) return true;
1257
- return !!el.closest('pre, code, kbd, samp, var, script, style, textarea, .math-pending, .hljs, .code-wrapper');
1258
- }
1259
-
1260
- // Global finder on a single text blob (original per-text-node logic).
1261
- findNextMatch(text, from, rules) {
1262
- let best = null;
1263
- for (const rule of rules) {
1264
- rule.re.lastIndex = from;
1265
- const m = rule.re.exec(text);
1266
- if (m) {
1267
- const start = m.index, end = rule.re.lastIndex;
1268
- if (!best || start < best.start) best = { rule, start, end, inner: m[1] || '' };
1269
- }
1270
- }
1271
- return best;
1272
- }
1273
-
1274
- // Strict full match of a pure text node (legacy path).
1275
- findFullMatch(text, rules) {
1276
- for (const rule of rules) {
1277
- if (rule.reFull) {
1278
- const m = rule.reFull.exec(text);
1279
- if (m) return { rule, inner: m[1] || '' };
1280
- } else {
1281
- // Legacy safety net (should not normally execute).
1282
- rule.re.lastIndex = 0;
1283
- const m = rule.re.exec(text);
1284
- if (m && m.index === 0 && (rule.re.lastIndex === text.length)) {
1285
- const m2 = rule.re.exec(text);
1286
- if (!m2) return { rule, inner: m[1] || '' };
1287
- }
1288
- }
1289
- }
1290
- return null;
1291
- }
1292
-
1293
- // Set inner content according to the rule's mode, with optional entity decode.
1294
- setInnerByMode(el, mode, text, MD, decodeEntities = false) {
1295
- let payload = String(text || '');
1296
- // Decode entities only when asked by the rule (prevents global behavior change).
1297
- if (decodeEntities && payload && payload.indexOf('&') !== -1) {
1298
- try { payload = this.decodeEntitiesOnce(payload); } catch (_) { /* keep original on failure */ }
1299
- }
1300
-
1301
- if (mode === 'markdown-inline' && typeof window.markdownit !== 'undefined') {
1302
- try {
1303
- if (MD && typeof MD.renderInline === 'function') { el.innerHTML = MD.renderInline(payload); return; }
1304
- const tempMD = window.markdownit({ html: false, linkify: true, breaks: true, highlight: () => '' });
1305
- el.innerHTML = tempMD.renderInline(payload); return;
1306
- } catch (_) {}
1307
- }
1308
- el.textContent = payload;
1309
- }
1310
-
1311
- // Try to replace an entire <p> that is a full custom markup match.
1312
- _tryReplaceFullParagraph(el, rules, MD) {
1313
- if (!el || el.tagName !== 'P') return false;
1314
- if (this.isInsideForbiddenElement(el)) {
1315
- this._d('P_SKIP_FORBIDDEN', { tag: el.tagName });
1316
- return false;
1317
1868
  }
1318
- const t = el.textContent || '';
1319
- if (t.indexOf('[!') === -1) return false;
1320
-
1321
- for (const rule of rules) {
1322
- if (!rule) continue;
1323
-
1324
- // Tolerant full-paragraph detection using textContent survives linkify splits.
1325
- const m = rule.reFullTrim ? rule.reFullTrim.exec(t) : null;
1326
- if (!m) continue;
1327
-
1328
- // IMPORTANT: create the replacement element using the rule's tag (was hard-coded to <p>).
1329
- // This makes [!cmd] ... [/!cmd] (configured with tag: 'div') work even when linkify
1330
- // inserted <a> tags inside the paragraph — detection is done on textContent, not DOM nodes.
1331
- const outTag = (rule.tag && typeof rule.tag === 'string') ? rule.tag.toLowerCase() : 'span';
1332
- const out = document.createElement(outTag === 'p' ? 'p' : outTag);
1333
- if (rule.className) out.className = rule.className;
1334
- out.setAttribute('data-cm', rule.name);
1335
-
1336
- const innerText = m[1] || '';
1337
- // Use mode-driven inner content materialization (text or markdown-inline) with optional decoding.
1338
- this.setInnerByMode(out, rule.innerMode, innerText, MD, !!rule.decodeEntities);
1339
-
1340
- // Replace the original <p> with the desired container (<div>, <think>, <p>, etc.).
1341
- try { el.replaceWith(out); } catch (_) {
1342
- const parent = el.parentNode; if (parent) parent.replaceChild(out, el);
1869
+ // Initialize markdown-it instances and plugins.
1870
+ init() {
1871
+ if (!window.markdownit) { this.logger.log('[MD] markdown-it not found – rendering skipped.'); return; }
1872
+ // Full renderer (used for non-hot paths, final results)
1873
+ this.MD = window.markdownit({ html: false, linkify: true, breaks: true, highlight: () => '' });
1874
+ // Streaming renderer (no linkify) – hot path
1875
+ this.MD_STREAM = window.markdownit({ html: false, linkify: false, breaks: true, highlight: () => '' });
1876
+
1877
+ // SAFETY: disable CommonMark "indented code blocks" unless explicitly enabled.
1878
+ if (!this.cfg.MD || this.cfg.MD.ALLOW_INDENTED_CODE !== true) {
1879
+ try { this.MD.block.ruler.disable('code'); } catch (_) {}
1880
+ try { this.MD_STREAM.block.ruler.disable('code'); } catch (_) {}
1343
1881
  }
1344
1882
 
1345
- this._d('P_REPLACED', { rule: rule.name, asTag: outTag, preview: this.logger.pv(t, 160) });
1346
- return true;
1347
- }
1348
- this._d('P_NO_FULL_MATCH', { preview: this.logger.pv(t, 160) });
1349
- return false;
1350
- }
1351
-
1352
- // Apply custom markup with two-phase strategy:
1353
- // 1) Full-paragraph tolerant pass (survives linkify splitting).
1354
- // 2) Legacy per-text-node pass for partial inline cases.
1355
- apply(root, MD) {
1356
- this.ensureCompiled();
1357
- const rules = this.__compiled;
1358
- if (!root || !rules || !rules.length) return;
1359
-
1360
- const scope = (root.nodeType === 1 || root.nodeType === 11) ? root : document;
1361
- try {
1362
- const paragraphs = (typeof scope.querySelectorAll === 'function') ? scope.querySelectorAll('p') : [];
1363
- this._d('P_TOLERANT_SCAN_START', { count: paragraphs.length });
1364
-
1365
- if (paragraphs && paragraphs.length) {
1366
- for (let i = 0; i < paragraphs.length; i++) {
1367
- const p = paragraphs[i];
1368
- if (p && p.getAttribute && p.getAttribute('data-cm')) continue;
1369
- // Quick check: avoid work if no marker in entire <p>
1370
- const tc = p && (p.textContent || '');
1371
- if (!tc || tc.indexOf('[!') === -1) continue;
1372
- this._tryReplaceFullParagraph(p, rules, MD);
1883
+ const escapeHtml = Utils.escapeHtml;
1884
+
1885
+ // Dollar and bracket math placeholder plugins: generate lightweight placeholders to be picked up by KaTeX later.
1886
+ const mathDollarPlaceholderPlugin = (md) => {
1887
+ function notEscaped(src, pos) { let back = 0; while (pos - back - 1 >= 0 && src.charCodeAt(pos - back - 1) === 0x5C) back++; return (back % 2) === 0; }
1888
+ function math_block_dollar(state, startLine, endLine, silent) {
1889
+ const pos = state.bMarks[startLine] + state.tShift[startLine];
1890
+ const max = state.eMarks[startLine];
1891
+ if (pos + 1 >= max) return false;
1892
+ if (state.src.charCodeAt(pos) !== 0x24 || state.src.charCodeAt(pos + 1) !== 0x24) return false;
1893
+ let nextLine = startLine + 1, found = false;
1894
+ for (; nextLine < endLine; nextLine++) {
1895
+ let p = state.bMarks[nextLine] + state.tShift[nextLine];
1896
+ const pe = state.eMarks[nextLine];
1897
+ if (p + 1 < pe && state.src.charCodeAt(p) === 0x24 && state.src.charCodeAt(p + 1) === 0x24) { found = true; break; }
1898
+ }
1899
+ if (!found) return false;
1900
+ if (silent) return true;
1901
+
1902
+ const contentStart = state.bMarks[startLine] + state.tShift[startLine] + 2;
1903
+ const contentEndLine = nextLine - 1;
1904
+ let content = '';
1905
+ if (contentEndLine >= startLine + 1) {
1906
+ const startIdx = state.bMarks[startLine + 1];
1907
+ const endIdx = state.eMarks[contentEndLine];
1908
+ content = state.src.slice(startIdx, endIdx);
1909
+ } else content = '';
1910
+
1911
+ const token = state.push('math_block_dollar', '', 0);
1912
+ token.block = true; token.content = content; state.line = nextLine + 1; return true;
1373
1913
  }
1374
- }
1375
- } catch (e) {
1376
- this._d('P_TOLERANT_SCAN_ERR', String(e));
1377
- }
1378
-
1379
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
1380
- acceptNode: (node) => {
1381
- if (!node || !node.nodeValue || node.nodeValue.indexOf('[!') === -1) return NodeFilter.FILTER_SKIP;
1382
- if (this.isInsideForbiddenContext(node)) return NodeFilter.FILTER_REJECT;
1383
- return NodeFilter.FILTER_ACCEPT;
1384
- }
1385
- });
1914
+ function math_inline_dollar(state, silent) {
1915
+ const pos = state.pos, src = state.src, max = state.posMax;
1916
+ if (pos >= max) return false;
1917
+ if (src.charCodeAt(pos) !== 0x24) return false;
1918
+ if (pos + 1 < max && src.charCodeAt(pos + 1) === 0x24) return false;
1919
+ const after = pos + 1 < max ? src.charCodeAt(pos + 1) : 0;
1920
+ if (after === 0x20 || after === 0x0A || after === 0x0D) return false;
1921
+ let i = pos + 1;
1922
+ while (i < max) {
1923
+ const ch = src.charCodeAt(i);
1924
+ if (ch === 0x24 && notEscaped(src, i)) {
1925
+ const before = i - 1 >= 0 ? src.charCodeAt(i - 1) : 0;
1926
+ if (before === 0x20 || before === 0x0A || before === 0x0D) { i++; continue; }
1927
+ break;
1928
+ }
1929
+ i++;
1930
+ }
1931
+ if (i >= max || src.charCodeAt(i) !== 0x24) return false;
1386
1932
 
1387
- let node;
1388
- while ((node = walker.nextNode())) {
1389
- const text = node.nodeValue;
1390
- if (!text || text.indexOf('[!') === -1) continue;
1391
-
1392
- const parent = node.parentElement;
1393
-
1394
- // Entire text node equals one full match and parent is <p>.
1395
- if (parent && parent.tagName === 'P' && parent.childNodes.length === 1) {
1396
- const fm = this.findFullMatch(text, rules);
1397
- if (fm && fm.rule.tag === 'p') {
1398
- const out = document.createElement('p');
1399
- if (fm.rule.className) out.className = fm.rule.className;
1400
- out.setAttribute('data-cm', fm.rule.name);
1401
- this.setInnerByMode(out, fm.rule.innerMode, fm.inner, MD, !!fm.rule.decodeEntities);
1402
- try { parent.replaceWith(out); } catch (_) {
1403
- const par = parent.parentNode; if (par) par.replaceChild(out, parent);
1933
+ if (!silent) {
1934
+ const token = state.push('math_inline_dollar', '', 0);
1935
+ token.block = false; token.content = src.slice(pos + 1, i);
1404
1936
  }
1405
- this._d('WALKER_FULL_REPLACE', { rule: fm.rule.name, preview: this.logger.pv(text, 160) });
1406
- continue;
1937
+ state.pos = i + 1; return true;
1407
1938
  }
1408
- }
1409
1939
 
1410
- // General inline replacement inside the text node (span-like).
1411
- let i = 0;
1412
- let didReplace = false;
1413
- const frag = document.createDocumentFragment();
1940
+ md.block.ruler.before('fence', 'math_block_dollar', math_block_dollar, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
1941
+ md.inline.ruler.before('escape', 'math_inline_dollar', math_inline_dollar);
1414
1942
 
1415
- while (i < text.length) {
1416
- const m = this.findNextMatch(text, i, rules);
1417
- if (!m) break;
1943
+ md.renderer.rules.math_inline_dollar = (tokens, idx) => {
1944
+ const tex = tokens[idx].content || '';
1945
+ return `<span class="math-pending" data-display="0"><span class="math-fallback">$${escapeHtml(tex)}$</span><script type="math/tex">${escapeHtml(tex)}</script></span>`;
1946
+ };
1947
+ md.renderer.rules.math_block_dollar = (tokens, idx) => {
1948
+ const tex = tokens[idx].content || '';
1949
+ return `<div class="math-pending" data-display="1"><div class="math-fallback">$$${escapeHtml(tex)}$$</div><script type="math/tex; mode=display">${escapeHtml(tex)}</script></div>`;
1950
+ };
1951
+ };
1418
1952
 
1419
- if (m.start > i) {
1420
- frag.appendChild(document.createTextNode(text.slice(i, m.start)));
1953
+ const mathBracketsPlaceholderPlugin = (md) => {
1954
+ function math_brackets(state, silent) {
1955
+ const src = state.src, pos = state.pos, max = state.posMax;
1956
+ if (pos + 1 >= max || src.charCodeAt(pos) !== 0x5C) return false;
1957
+ const next = src.charCodeAt(pos + 1);
1958
+ if (next !== 0x28 && next !== 0x5B) return false;
1959
+ const isInline = (next === 0x28); const close = isInline ? '\\)' : '\\]';
1960
+ const start = pos + 2; const end = src.indexOf(close, start);
1961
+ if (end < 0) return false;
1962
+ const content = src.slice(start, end);
1963
+ if (!silent) {
1964
+ const t = state.push(isInline ? 'math_inline_bracket' : 'math_block_bracket', '', 0);
1965
+ t.content = content; t.block = !isInline;
1966
+ }
1967
+ state.pos = end + 2; return true;
1421
1968
  }
1969
+ md.inline.ruler.before('escape', 'math_brackets', math_brackets);
1970
+ md.renderer.rules.math_inline_bracket = (tokens, idx) => {
1971
+ const tex = tokens[idx].content || '';
1972
+ return `<span class="math-pending" data-display="0"><span class="math-fallback">\\(${escapeHtml(tex)}\\)</span><script type="math/tex">${escapeHtml(tex)}</script></span>`;
1973
+ };
1974
+ md.renderer.rules.math_block_bracket = (tokens, idx) => {
1975
+ const tex = tokens[idx].content || '';
1976
+ return `<div class="math-pending" data-display="1"><div class="math-fallback">\\[${escapeHtml(tex)}\\]</div><script type="math/tex; mode=display">${escapeHtml(tex)}</script></div>`;
1977
+ };
1978
+ };
1422
1979
 
1423
- const tag = (m.rule.tag === 'p') ? 'span' : m.rule.tag;
1424
- const el = document.createElement(tag);
1425
- if (m.rule.className) el.className = m.rule.className;
1426
- el.setAttribute('data-cm', m.rule.name);
1427
- this.setInnerByMode(el, m.rule.innerMode, m.inner, MD, !!m.rule.decodeEntities);
1428
-
1429
- frag.appendChild(el);
1430
- this._d('WALKER_INLINE_MATCH', { rule: m.rule.name, start: m.start, end: m.end });
1431
- i = m.end;
1432
- didReplace = true;
1433
- }
1434
-
1435
- if (!didReplace) continue;
1436
-
1437
- if (i < text.length) {
1438
- frag.appendChild(document.createTextNode(text.slice(i)));
1439
- }
1440
-
1441
- const parentNode = node.parentNode;
1442
- if (parentNode) {
1443
- parentNode.replaceChild(frag, node);
1444
- this._d('WALKER_INLINE_DONE', { preview: this.logger.pv(text, 120) });
1445
- }
1446
- }
1447
- }
1448
- }
1980
+ this.MD.use(mathDollarPlaceholderPlugin);
1981
+ this.MD.use(mathBracketsPlaceholderPlugin);
1982
+ this.MD_STREAM.use(mathDollarPlaceholderPlugin);
1983
+ this.MD_STREAM.use(mathBracketsPlaceholderPlugin);
1449
1984
 
1450
- // ==========================================================================
1451
- // 5) Markdown runtime (markdown-it + code wrapper + math placeholders)
1452
- // ==========================================================================
1985
+ const cfg = this.cfg; const logger = this.logger;
1453
1986
 
1454
- class MarkdownRenderer {
1455
- constructor(cfg, customMarkup, logger, asyncer, raf) {
1456
- this.cfg = cfg; this.customMarkup = customMarkup; this.MD = null;
1457
- this.logger = logger || new Logger(cfg);
1458
- // Cooperative async utilities available in renderer for heavy decode/render paths
1459
- this.asyncer = asyncer || new AsyncRunner(cfg, raf);
1460
- this.raf = raf || null;
1987
+ // STREAMING wrapper plugin (modified header label guard)
1988
+ (function codeWrapperPlugin(md, logger) {
1989
+ let CODE_IDX = 1;
1990
+ const log = (line, ctx) => logger.debug('MD_LANG', line, ctx);
1461
1991
 
1462
- // Fast-path streaming renderer without linkify to reduce regex work on hot path.
1463
- this.MD_STREAM = null;
1992
+ const DEDUP = (window.MD_LANG_LOG_DEDUP !== false);
1993
+ const seenFP = new Set();
1994
+ const makeFP = (info, raw) => {
1995
+ const head = (raw || '').slice(0, 96);
1996
+ return String(info || '') + '|' + String((raw || '').length) + '|' + head;
1997
+ };
1464
1998
 
1465
- this.hooks = {
1466
- observeNewCode: () => {},
1467
- observeMsgBoxes: () => {},
1468
- scheduleMathRender: () => {},
1469
- codeScrollInit: () => {}
1470
- };
1471
- }
1472
- // Initialize markdown-it instances and plugins.
1473
- init() {
1474
- if (!window.markdownit) { this.logger.log('[MD] markdown-it not found – rendering skipped.'); return; }
1475
- // Full renderer (used for non-hot paths, final results)
1476
- this.MD = window.markdownit({ html: false, linkify: true, breaks: true, highlight: () => '' });
1477
- // Streaming renderer (no linkify) hot path
1478
- this.MD_STREAM = window.markdownit({ html: false, linkify: false, breaks: true, highlight: () => '' });
1479
-
1480
- // SAFETY: disable CommonMark "indented code blocks" unless explicitly enabled.
1481
- if (!this.cfg.MD || this.cfg.MD.ALLOW_INDENTED_CODE !== true) {
1482
- try { this.MD.block.ruler.disable('code'); } catch (_) {}
1483
- try { this.MD_STREAM.block.ruler.disable('code'); } catch (_) {}
1484
- }
1485
-
1486
- const escapeHtml = Utils.escapeHtml;
1487
-
1488
- // Dollar and bracket math placeholder plugins: generate lightweight placeholders to be picked up by KaTeX later.
1489
- const mathDollarPlaceholderPlugin = (md) => {
1490
- function notEscaped(src, pos) { let back = 0; while (pos - back - 1 >= 0 && src.charCodeAt(pos - back - 1) === 0x5C) back++; return (back % 2) === 0; }
1491
- function math_block_dollar(state, startLine, endLine, silent) {
1492
- const pos = state.bMarks[startLine] + state.tShift[startLine];
1493
- const max = state.eMarks[startLine];
1494
- if (pos + 1 >= max) return false;
1495
- if (state.src.charCodeAt(pos) !== 0x24 || state.src.charCodeAt(pos + 1) !== 0x24) return false;
1496
- let nextLine = startLine + 1, found = false;
1497
- for (; nextLine < endLine; nextLine++) {
1498
- let p = state.bMarks[nextLine] + state.tShift[nextLine];
1499
- const pe = state.eMarks[nextLine];
1500
- if (p + 1 < pe && state.src.charCodeAt(p) === 0x24 && state.src.charCodeAt(p + 1) === 0x24) { found = true; break; }
1501
- }
1502
- if (!found) return false;
1503
- if (silent) return true;
1504
-
1505
- const contentStart = state.bMarks[startLine] + state.tShift[startLine] + 2;
1506
- const contentEndLine = nextLine - 1;
1507
- let content = '';
1508
- if (contentEndLine >= startLine + 1) {
1509
- const startIdx = state.bMarks[startLine + 1];
1510
- const endIdx = state.eMarks[contentEndLine];
1511
- content = state.src.slice(startIdx, endIdx);
1512
- } else content = '';
1513
-
1514
- const token = state.push('math_block_dollar', '', 0);
1515
- token.block = true; token.content = content; state.line = nextLine + 1; return true;
1516
- }
1517
- function math_inline_dollar(state, silent) {
1518
- const pos = state.pos, src = state.src, max = state.posMax;
1519
- if (pos >= max) return false;
1520
- if (src.charCodeAt(pos) !== 0x24) return false;
1521
- if (pos + 1 < max && src.charCodeAt(pos + 1) === 0x24) return false;
1522
- const after = pos + 1 < max ? src.charCodeAt(pos + 1) : 0;
1523
- if (after === 0x20 || after === 0x0A || after === 0x0D) return false;
1524
- let i = pos + 1;
1525
- while (i < max) {
1526
- const ch = src.charCodeAt(i);
1527
- if (ch === 0x24 && notEscaped(src, i)) {
1528
- const before = i - 1 >= 0 ? src.charCodeAt(i - 1) : 0;
1529
- if (before === 0x20 || before === 0x0A || before === 0x0D) { i++; continue; }
1530
- break;
1999
+ const ALIAS = {
2000
+ txt: 'plaintext', text: 'plaintext', plaintext: 'plaintext',
2001
+ sh: 'bash', shell: 'bash', zsh: 'bash', 'shell-session': 'bash',
2002
+ py: 'python', python3: 'python', py3: 'python',
2003
+ js: 'javascript', node: 'javascript', nodejs: 'javascript',
2004
+ ts: 'typescript', 'ts-node': 'typescript',
2005
+ yml: 'yaml', kt: 'kotlin', rs: 'rust',
2006
+ csharp: 'csharp', 'c#': 'csharp', 'c++': 'cpp',
2007
+ ps: 'powershell', ps1: 'powershell', pwsh: 'powershell', powershell7: 'powershell',
2008
+ docker: 'dockerfile'
2009
+ };
2010
+ function normLang(s) { if (!s) return ''; const v = String(s).trim().toLowerCase(); return ALIAS[v] || v; }
2011
+ function isSupportedByHLJS(lang) { try { return !!(window.hljs && hljs.getLanguage && hljs.getLanguage(lang)); } catch (_) { return false; } }
2012
+ function classForHighlight(lang) { if (!lang) return 'plaintext'; return isSupportedByHLJS(lang) ? lang : 'plaintext'; }
2013
+ function stripBOM(s) { return (s && s.charCodeAt(0) === 0xFEFF) ? s.slice(1) : s; }
2014
+
2015
+ function detectFromFirstLine(raw, rid) {
2016
+ if (!raw) return { lang: '', content: raw, isOutput: false };
2017
+ const lines = raw.split(/\r?\n/);
2018
+ if (!lines.length) return { lang: '', content: raw, isOutput: false };
2019
+ let i = 0; while (i < lines.length && !lines[i].trim()) i++;
2020
+ if (i >= lines.length) { log(`#${rid} first-line: only whitespace`); return { lang: '', content: raw, isOutput: false }; }
2021
+ let first = stripBOM(lines[i]).trim();
2022
+ first = first.replace(/^\s*lang(?:uage)?\s*[:=]\s*/i, '').trim();
2023
+ let token = first.split(/\s+/)[0].replace(/:$/, '');
2024
+ if (!/^[A-Za-z][\w#+\-\.]{0,30}$/.test(token)) { log(`#${rid} first-line: no token match`, { first }); return { lang: '', content: raw, isOutput: false }; }
2025
+ let cand = normLang(token);
2026
+ if (cand === 'output') {
2027
+ const content = lines.slice(i + 1).join('\n');
2028
+ log(`#${rid} first-line: output header`);
2029
+ return { lang: 'python', headerLabel: 'output', content, isOutput: true };
1531
2030
  }
1532
- i++;
2031
+ const rest = lines.slice(i + 1).join('\n');
2032
+ if (!rest.trim()) { log(`#${rid} first-line: directive but no content after, ignore`, { cand }); return { lang: '', content: raw, isOutput: false }; }
2033
+ log(`#${rid} first-line: directive accepted`, { cand, restLen: rest.length, hljs: isSupportedByHLJS(cand) });
2034
+ return { lang: cand, headerLabel: cand, content: rest, isOutput: false };
1533
2035
  }
1534
- if (i >= max || src.charCodeAt(i) !== 0x24) return false;
1535
2036
 
1536
- if (!silent) {
1537
- const token = state.push('math_inline_dollar', '', 0);
1538
- token.block = false; token.content = src.slice(pos + 1, i);
2037
+ md.renderer.rules.fence = (tokens, idx) => renderFence(tokens[idx]);
2038
+ md.renderer.rules.code_block = (tokens, idx) => renderFence({ info: '', content: tokens[idx].content || '' });
2039
+
2040
+ function resolveLanguageAndContent(info, raw, rid) {
2041
+ const infoLangRaw = (info || '').trim().split(/\s+/)[0] || '';
2042
+ let cand = normLang(infoLangRaw);
2043
+ if (cand === 'output') {
2044
+ log(`#${rid} info: output header`);
2045
+ return { lang: 'python', headerLabel: 'output', content: raw, isOutput: true };
2046
+ }
2047
+ if (cand) {
2048
+ log(`#${rid} info: token`, { infoLangRaw, cand, hljs: isSupportedByHLJS(cand) });
2049
+ return { lang: cand, headerLabel: cand, content: raw, isOutput: false };
2050
+ }
2051
+ const det = detectFromFirstLine(raw, rid);
2052
+ if (det && (det.lang || det.isOutput)) return det;
2053
+ log(`#${rid} resolve: fallback`);
2054
+ return { lang: '', headerLabel: 'code', content: raw, isOutput: false };
1539
2055
  }
1540
- state.pos = i + 1; return true;
1541
- }
1542
2056
 
1543
- md.block.ruler.before('fence', 'math_block_dollar', math_block_dollar, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
1544
- md.inline.ruler.before('escape', 'math_inline_dollar', math_inline_dollar);
2057
+ function renderFence(token) {
2058
+ const raw = token.content || '';
2059
+ const rid = String(CODE_IDX + '');
2060
+ const fp = makeFP(token.info || '', raw);
2061
+ const canLog = !DEDUP || !seenFP.has(fp);
2062
+ if (canLog) log(`FENCE_ENTER #${rid}`, { info: (token.info || ''), rawHead: logger.pv(raw) });
2063
+
2064
+ const res = resolveLanguageAndContent(token.info || '', raw, rid);
2065
+ const isOutput = !!res.isOutput;
2066
+
2067
+ // Choose class and a safe header label (avoid 'on', 'ml', 's' etc.)
2068
+ const rawToken = (res.lang || '').trim();
2069
+ const langClass = isOutput ? 'python' : classForHighlight(rawToken);
2070
+
2071
+ // Guard against tiny unsupported tokens – show temporary 'code' instead of partial suffix.
2072
+ let headerLabel = isOutput ? 'output' : (res.headerLabel || (rawToken || 'code'));
2073
+ if (!isOutput) {
2074
+ if (rawToken && !isSupportedByHLJS(rawToken) && rawToken.length < 3) {
2075
+ headerLabel = 'code';
2076
+ }
2077
+ }
1545
2078
 
1546
- md.renderer.rules.math_inline_dollar = (tokens, idx) => {
1547
- const tex = tokens[idx].content || '';
1548
- return `<span class="math-pending" data-display="0"><span class="math-fallback">$${escapeHtml(tex)}$</span><script type="math/tex">${escapeHtml(tex)}</script></span>`;
1549
- };
1550
- md.renderer.rules.math_block_dollar = (tokens, idx) => {
1551
- const tex = tokens[idx].content || '';
1552
- return `<div class="math-pending" data-display="1"><div class="math-fallback">$$${escapeHtml(tex)}$$</div><script type="math/tex; mode=display">${escapeHtml(tex)}</script></div>`;
1553
- };
1554
- };
2079
+ if (canLog) {
2080
+ log(`FENCE_RESOLVE #${rid}`, { headerLabel, langToken: (res.lang || ''), langClass, hljsSupported: isSupportedByHLJS(res.lang || ''), contentLen: (res.content || '').length });
2081
+ if (DEDUP) seenFP.add(fp);
2082
+ }
1555
2083
 
1556
- const mathBracketsPlaceholderPlugin = (md) => {
1557
- function math_brackets(state, silent) {
1558
- const src = state.src, pos = state.pos, max = state.posMax;
1559
- if (pos + 1 >= max || src.charCodeAt(pos) !== 0x5C) return false;
1560
- const next = src.charCodeAt(pos + 1);
1561
- if (next !== 0x28 && next !== 0x5B) return false;
1562
- const isInline = (next === 0x28); const close = isInline ? '\\)' : '\\]';
1563
- const start = pos + 2; const end = src.indexOf(close, start);
1564
- if (end < 0) return false;
1565
- const content = src.slice(start, end);
1566
- if (!silent) {
1567
- const t = state.push(isInline ? 'math_inline_bracket' : 'math_block_bracket', '', 0);
1568
- t.content = content; t.block = !isInline;
2084
+ // precompute code meta to avoid expensive .textContent on next phases
2085
+ const content = res.content || '';
2086
+ const len = content.length;
2087
+ const head = content.slice(0, 64);
2088
+ const tail = content.slice(-64);
2089
+ const headEsc = Utils.escapeHtml(head);
2090
+ const tailEsc = Utils.escapeHtml(tail);
2091
+ // Note: for full renderer we will also persist data-code-nl (see below).
2092
+
2093
+ const inner = Utils.escapeHtml(content);
2094
+ const idxLocal = CODE_IDX++;
2095
+
2096
+ let actions = '';
2097
+ if (langClass === 'html') {
2098
+ actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-preview"><img src="${cfg.ICONS.CODE_PREVIEW}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.PREVIEW)}</span></a>`;
2099
+ } else if (langClass === 'python' && headerLabel !== 'output') {
2100
+ actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-run"><img src="${cfg.ICONS.CODE_RUN}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.RUN)}</span></a>`;
2101
+ }
2102
+ actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-collapse"><img src="${cfg.ICONS.CODE_MENU}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.COLLAPSE)}</span></a>`;
2103
+ actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-copy"><img src="${cfg.ICONS.CODE_COPY}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.COPY)}</span></a>`;
2104
+
2105
+ // attach precomputed meta (len/head/tail) on wrapper for downstream optimizations
2106
+ return (
2107
+ `<div class="code-wrapper highlight" data-index="${idxLocal}"` +
2108
+ ` data-code-lang="${Utils.escapeHtml(res.lang || '')}"` +
2109
+ ` data-code-len="${String(len)}" data-code-head="${headEsc}" data-code-tail="${tailEsc}"` + // meta (no nl here – only in full renderer)
2110
+ ` data-locale-collapse="${Utils.escapeHtml(cfg.LOCALE.COLLAPSE)}" data-locale-expand="${Utils.escapeHtml(cfg.LOCALE.EXPAND)}"` +
2111
+ ` data-locale-copy="${Utils.escapeHtml(cfg.LOCALE.COPY)}" data-locale-copied="${Utils.escapeHtml(cfg.LOCALE.COPIED)}" data-style="${Utils.escapeHtml(cfg.CODE_STYLE)}">` +
2112
+ `<p class="code-header-wrapper"><span><span class="code-header-lang">${Utils.escapeHtml(headerLabel)} </span>${actions}</span></p>` +
2113
+ `<pre><code class="language-${Utils.escapeHtml(langClass)} hljs">${inner}</code></pre>` +
2114
+ `</div>`
2115
+ );
1569
2116
  }
1570
- state.pos = end + 2; return true;
1571
- }
1572
- md.inline.ruler.before('escape', 'math_brackets', math_brackets);
1573
- md.renderer.rules.math_inline_bracket = (tokens, idx) => {
1574
- const tex = tokens[idx].content || '';
1575
- return `<span class="math-pending" data-display="0"><span class="math-fallback">\\(${escapeHtml(tex)}\\)</span><script type="math/tex">${escapeHtml(tex)}</script></span>`;
1576
- };
1577
- md.renderer.rules.math_block_bracket = (tokens, idx) => {
1578
- const tex = tokens[idx].content || '';
1579
- return `<div class="math-pending" data-display="1"><div class="math-fallback">\\[${escapeHtml(tex)}\\]</div><script type="math/tex; mode=display">${escapeHtml(tex)}</script></div>`;
1580
- };
1581
- };
1582
-
1583
- this.MD.use(mathDollarPlaceholderPlugin);
1584
- this.MD.use(mathBracketsPlaceholderPlugin);
1585
- this.MD_STREAM.use(mathDollarPlaceholderPlugin);
1586
- this.MD_STREAM.use(mathBracketsPlaceholderPlugin);
1587
-
1588
- const cfg = this.cfg; const logger = this.logger;
1589
- (function codeWrapperPlugin(md, logger) {
1590
- let CODE_IDX = 1;
1591
- const log = (line, ctx) => logger.debug('MD_LANG', line, ctx);
1592
-
1593
- const DEDUP = (window.MD_LANG_LOG_DEDUP !== false);
1594
- const seenFP = new Set();
1595
- const makeFP = (info, raw) => {
1596
- const head = (raw || '').slice(0, 96);
1597
- return String(info || '') + '|' + String((raw || '').length) + '|' + head;
1598
- };
2117
+ })(this.MD_STREAM, this.logger);
2118
+
2119
+ // FULL renderer wrapper plugin (modified header label guard)
2120
+ (function codeWrapperPlugin(md, logger) {
2121
+ // identical core logic augmented with data-code-nl for full renderer
2122
+ let CODE_IDX = 1;
2123
+ const log = (line, ctx) => logger.debug('MD_LANG', line, ctx);
2124
+
2125
+ const DEDUP = (window.MD_LANG_LOG_DEDUP !== false);
2126
+ const seenFP = new Set();
2127
+ const makeFP = (info, raw) => {
2128
+ const head = (raw || '').slice(0, 96);
2129
+ return String(info || '') + '|' + String((raw || '').length) + '|' + head;
2130
+ };
1599
2131
 
1600
- const ALIAS = {
1601
- txt: 'plaintext', text: 'plaintext', plaintext: 'plaintext',
1602
- sh: 'bash', shell: 'bash', zsh: 'bash', 'shell-session': 'bash',
1603
- py: 'python', python3: 'python', py3: 'python',
1604
- js: 'javascript', node: 'javascript', nodejs: 'javascript',
1605
- ts: 'typescript', 'ts-node': 'typescript',
1606
- yml: 'yaml', kt: 'kotlin', rs: 'rust',
1607
- csharp: 'csharp', 'c#': 'csharp', 'c++': 'cpp',
1608
- ps: 'powershell', ps1: 'powershell', pwsh: 'powershell', powershell7: 'powershell',
1609
- docker: 'dockerfile'
1610
- };
1611
- function normLang(s) { if (!s) return ''; const v = String(s).trim().toLowerCase(); return ALIAS[v] || v; }
1612
- function isSupportedByHLJS(lang) { try { return !!(window.hljs && hljs.getLanguage && hljs.getLanguage(lang)); } catch (_) { return false; } }
1613
- function classForHighlight(lang) { if (!lang) return 'plaintext'; return isSupportedByHLJS(lang) ? lang : 'plaintext'; }
1614
- function stripBOM(s) { return (s && s.charCodeAt(0) === 0xFEFF) ? s.slice(1) : s; }
1615
-
1616
- function detectFromFirstLine(raw, rid) {
1617
- if (!raw) return { lang: '', content: raw, isOutput: false };
1618
- const lines = raw.split(/\r?\n/);
1619
- if (!lines.length) return { lang: '', content: raw, isOutput: false };
1620
- let i = 0; while (i < lines.length && !lines[i].trim()) i++;
1621
- if (i >= lines.length) { log(`#${rid} first-line: only whitespace`); return { lang: '', content: raw, isOutput: false }; }
1622
- let first = stripBOM(lines[i]).trim();
1623
- first = first.replace(/^\s*lang(?:uage)?\s*[:=]\s*/i, '').trim();
1624
- let token = first.split(/\s+/)[0].replace(/:$/, '');
1625
- if (!/^[A-Za-z][\w#+\-\.]{0,30}$/.test(token)) { log(`#${rid} first-line: no token match`, { first }); return { lang: '', content: raw, isOutput: false }; }
1626
- let cand = normLang(token);
1627
- if (cand === 'output') {
1628
- const content = lines.slice(i + 1).join('\n');
1629
- log(`#${rid} first-line: output header`);
1630
- return { lang: 'python', headerLabel: 'output', content, isOutput: true };
2132
+ const ALIAS = {
2133
+ txt: 'plaintext', text: 'plaintext', plaintext: 'plaintext',
2134
+ sh: 'bash', shell: 'bash', zsh: 'bash', 'shell-session': 'bash',
2135
+ py: 'python', python3: 'python', py3: 'python',
2136
+ js: 'javascript', node: 'javascript', nodejs: 'javascript',
2137
+ ts: 'typescript', 'ts-node': 'typescript',
2138
+ yml: 'yaml', kt: 'kotlin', rs: 'rust',
2139
+ csharp: 'csharp', 'c#': 'csharp', 'c++': 'cpp',
2140
+ ps: 'powershell', ps1: 'powershell', pwsh: 'powershell', powershell7: 'powershell',
2141
+ docker: 'dockerfile'
2142
+ };
2143
+ function normLang(s) { if (!s) return ''; const v = String(s).trim().toLowerCase(); return ALIAS[v] || v; }
2144
+ function isSupportedByHLJS(lang) { try { return !!(window.hljs && hljs.getLanguage && hljs.getLanguage(lang)); } catch (_) { return false; } }
2145
+ function classForHighlight(lang) { if (!lang) return 'plaintext'; return isSupportedByHLJS(lang) ? lang : 'plaintext'; }
2146
+ function stripBOM(s) { return (s && s.charCodeAt(0) === 0xFEFF) ? s.slice(1) : s; }
2147
+
2148
+ function detectFromFirstLine(raw, rid) {
2149
+ if (!raw) return { lang: '', content: raw, isOutput: false };
2150
+ const lines = raw.split(/\r?\n/);
2151
+ if (!lines.length) return { lang: '', content: raw, isOutput: false };
2152
+ let i = 0; while (i < lines.length && !lines[i].trim()) i++;
2153
+ if (i >= lines.length) { log(`#${rid} first-line: only whitespace`); return { lang: '', content: raw, isOutput: false }; }
2154
+ let first = stripBOM(lines[i]).trim();
2155
+ first = first.replace(/^\s*lang(?:uage)?\s*[:=]\s*/i, '').trim();
2156
+ let token = first.split(/\s+/)[0].replace(/:$/, '');
2157
+ if (!/^[A-Za-z][\w#+\-\.]{0,30}$/.test(token)) { log(`#${rid} first-line: no token match`, { first }); return { lang: '', content: raw, isOutput: false }; }
2158
+ let cand = normLang(token);
2159
+ if (cand === 'output') {
2160
+ const content = lines.slice(i + 1).join('\n');
2161
+ log(`#${rid} first-line: output header`);
2162
+ return { lang: 'python', headerLabel: 'output', content, isOutput: true };
2163
+ }
2164
+ const rest = lines.slice(i + 1).join('\n');
2165
+ if (!rest.trim()) { log(`#${rid} first-line: directive but no content after, ignore`, { cand }); return { lang: '', content: raw, isOutput: false }; }
2166
+ log(`#${rid} first-line: directive accepted`, { cand, restLen: rest.length, hljs: isSupportedByHLJS(cand) });
2167
+ return { lang: cand, headerLabel: cand, content: rest, isOutput: false };
1631
2168
  }
1632
- const rest = lines.slice(i + 1).join('\n');
1633
- if (!rest.trim()) { log(`#${rid} first-line: directive but no content after, ignore`, { cand }); return { lang: '', content: raw, isOutput: false }; }
1634
- log(`#${rid} first-line: directive accepted`, { cand, restLen: rest.length, hljs: isSupportedByHLJS(cand) });
1635
- return { lang: cand, headerLabel: cand, content: rest, isOutput: false };
1636
- }
1637
2169
 
1638
- md.renderer.rules.fence = (tokens, idx) => renderFence(tokens[idx]);
1639
- md.renderer.rules.code_block = (tokens, idx) => renderFence({ info: '', content: tokens[idx].content || '' });
2170
+ md.renderer.rules.fence = (tokens, idx) => renderFence(tokens[idx]);
2171
+ md.renderer.rules.code_block = (tokens, idx) => renderFence({ info: '', content: tokens[idx].content || '' });
1640
2172
 
1641
- function resolveLanguageAndContent(info, raw, rid) {
1642
- const infoLangRaw = (info || '').trim().split(/\s+/)[0] || '';
1643
- let cand = normLang(infoLangRaw);
1644
- if (cand === 'output') {
1645
- log(`#${rid} info: output header`);
1646
- return { lang: 'python', headerLabel: 'output', content: raw, isOutput: true };
1647
- }
1648
- if (cand) {
1649
- log(`#${rid} info: token`, { infoLangRaw, cand, hljs: isSupportedByHLJS(cand) });
1650
- return { lang: cand, headerLabel: cand, content: raw, isOutput: false };
2173
+ function resolveLanguageAndContent(info, raw, rid) {
2174
+ const infoLangRaw = (info || '').trim().split(/\s+/)[0] || '';
2175
+ let cand = normLang(infoLangRaw);
2176
+ if (cand === 'output') {
2177
+ log(`#${rid} info: output header`);
2178
+ return { lang: 'python', headerLabel: 'output', content: raw, isOutput: true };
2179
+ }
2180
+ if (cand) {
2181
+ log(`#${rid} info: token`, { infoLangRaw, cand, hljs: isSupportedByHLJS(cand) });
2182
+ return { lang: cand, headerLabel: cand, content: raw, isOutput: false };
2183
+ }
2184
+ const det = detectFromFirstLine(raw, rid);
2185
+ if (det && (det.lang || det.isOutput)) return det;
2186
+ log(`#${rid} resolve: fallback`);
2187
+ return { lang: '', headerLabel: 'code', content: raw, isOutput: false };
1651
2188
  }
1652
- const det = detectFromFirstLine(raw, rid);
1653
- if (det && (det.lang || det.isOutput)) return det;
1654
- log(`#${rid} resolve: fallback`);
1655
- return { lang: '', headerLabel: 'code', content: raw, isOutput: false };
1656
- }
1657
2189
 
1658
- function renderFence(token) {
1659
- const raw = token.content || '';
1660
- const rid = String(CODE_IDX + '');
1661
- const fp = makeFP(token.info || '', raw);
1662
- const canLog = !DEDUP || !seenFP.has(fp);
1663
- if (canLog) log(`FENCE_ENTER #${rid}`, { info: (token.info || ''), rawHead: logger.pv(raw) });
1664
-
1665
- const res = resolveLanguageAndContent(token.info || '', raw, rid);
1666
- const isOutput = !!res.isOutput;
1667
- const headerLabel = isOutput ? 'output' : (res.headerLabel || (res.lang || 'code'));
1668
- const langClass = isOutput ? 'python' : classForHighlight(res.lang);
1669
-
1670
- if (canLog) {
1671
- log(`FENCE_RESOLVE #${rid}`, { headerLabel, langToken: (res.lang || ''), langClass, hljsSupported: isSupportedByHLJS(res.lang || ''), contentLen: (res.content || '').length });
1672
- if (DEDUP) seenFP.add(fp);
1673
- }
2190
+ function renderFence(token) {
2191
+ const raw = token.content || '';
2192
+ const rid = String(CODE_IDX + '');
2193
+ const fp = makeFP(token.info || '', raw);
2194
+ const canLog = !DEDUP || !seenFP.has(fp);
2195
+ if (canLog) log(`FENCE_ENTER #${rid}`, { info: (token.info || ''), rawHead: logger.pv(raw) });
2196
+
2197
+ const res = resolveLanguageAndContent(token.info || '', raw, rid);
2198
+ const isOutput = !!res.isOutput;
2199
+
2200
+ // Choose class and a safe header label (avoid 'on', 'ml', 's' etc.)
2201
+ const rawToken = (res.lang || '').trim();
2202
+ const langClass = isOutput ? 'python' : classForHighlight(rawToken);
2203
+
2204
+ // Guard against tiny unsupported tokens – show temporary 'code' instead of partial suffix.
2205
+ let headerLabel = isOutput ? 'output' : (res.headerLabel || (rawToken || 'code'));
2206
+ if (!isOutput) {
2207
+ if (rawToken && !isSupportedByHLJS(rawToken) && rawToken.length < 3) {
2208
+ headerLabel = 'code';
2209
+ }
2210
+ }
1674
2211
 
1675
- // precompute code meta to avoid expensive .textContent on next phases
1676
- const content = res.content || '';
1677
- const len = content.length;
1678
- const head = content.slice(0, 64);
1679
- const tail = content.slice(-64);
1680
- const headEsc = Utils.escapeHtml(head);
1681
- const tailEsc = Utils.escapeHtml(tail);
1682
- // Note: for full renderer we will also persist data-code-nl (see below).
1683
-
1684
- const inner = Utils.escapeHtml(content);
1685
- const idxLocal = CODE_IDX++;
1686
-
1687
- let actions = '';
1688
- if (langClass === 'html') {
1689
- actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-preview"><img src="${cfg.ICONS.CODE_PREVIEW}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.PREVIEW)}</span></a>`;
1690
- } else if (langClass === 'python' && headerLabel !== 'output') {
1691
- actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-run"><img src="${cfg.ICONS.CODE_RUN}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.RUN)}</span></a>`;
1692
- }
1693
- actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-collapse"><img src="${cfg.ICONS.CODE_MENU}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.COLLAPSE)}</span></a>`;
1694
- actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-copy"><img src="${cfg.ICONS.CODE_COPY}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.COPY)}</span></a>`;
1695
-
1696
- // attach precomputed meta (len/head/tail) on wrapper for downstream optimizations
1697
- return (
1698
- `<div class="code-wrapper highlight" data-index="${idxLocal}"` +
1699
- ` data-code-lang="${Utils.escapeHtml(res.lang || '')}"` +
1700
- ` data-code-len="${String(len)}" data-code-head="${headEsc}" data-code-tail="${tailEsc}"` + // meta (no nl here – only in full renderer)
1701
- ` data-locale-collapse="${Utils.escapeHtml(cfg.LOCALE.COLLAPSE)}" data-locale-expand="${Utils.escapeHtml(cfg.LOCALE.EXPAND)}"` +
1702
- ` data-locale-copy="${Utils.escapeHtml(cfg.LOCALE.COPY)}" data-locale-copied="${Utils.escapeHtml(cfg.LOCALE.COPIED)}" data-style="${Utils.escapeHtml(cfg.CODE_STYLE)}">` +
1703
- `<p class="code-header-wrapper"><span><span class="code-header-lang">${Utils.escapeHtml(headerLabel)} </span>${actions}</span></p>` +
1704
- `<pre><code class="language-${Utils.escapeHtml(langClass)} hljs">${inner}</code></pre>` +
1705
- `</div>`
1706
- );
1707
- }
1708
- })(this.MD_STREAM, this.logger);
1709
-
1710
- // Apply wrapper plugin to full renderer with extra meta (includes number of lines).
1711
- (function codeWrapperPlugin(md, logger) {
1712
- // identical core logic – augmented with data-code-nl for full renderer
1713
- let CODE_IDX = 1;
1714
- const log = (line, ctx) => logger.debug('MD_LANG', line, ctx);
1715
-
1716
- const DEDUP = (window.MD_LANG_LOG_DEDUP !== false);
1717
- const seenFP = new Set();
1718
- const makeFP = (info, raw) => {
1719
- const head = (raw || '').slice(0, 96);
1720
- return String(info || '') + '|' + String((raw || '').length) + '|' + head;
1721
- };
2212
+ if (canLog) {
2213
+ log(`FENCE_RESOLVE #${rid}`, { headerLabel, langToken: (res.lang || ''), langClass, hljsSupported: isSupportedByHLJS(res.lang || ''), contentLen: (res.content || '').length });
2214
+ if (DEDUP) seenFP.add(fp);
2215
+ }
1722
2216
 
1723
- const ALIAS = {
1724
- txt: 'plaintext', text: 'plaintext', plaintext: 'plaintext',
1725
- sh: 'bash', shell: 'bash', zsh: 'bash', 'shell-session': 'bash',
1726
- py: 'python', python3: 'python', py3: 'python',
1727
- js: 'javascript', node: 'javascript', nodejs: 'javascript',
1728
- ts: 'typescript', 'ts-node': 'typescript',
1729
- yml: 'yaml', kt: 'kotlin', rs: 'rust',
1730
- csharp: 'csharp', 'c#': 'csharp', 'c++': 'cpp',
1731
- ps: 'powershell', ps1: 'powershell', pwsh: 'powershell', powershell7: 'powershell',
1732
- docker: 'dockerfile'
1733
- };
1734
- function normLang(s) { if (!s) return ''; const v = String(s).trim().toLowerCase(); return ALIAS[v] || v; }
1735
- function isSupportedByHLJS(lang) { try { return !!(window.hljs && hljs.getLanguage && hljs.getLanguage(lang)); } catch (_) { return false; } }
1736
- function classForHighlight(lang) { if (!lang) return 'plaintext'; return isSupportedByHLJS(lang) ? lang : 'plaintext'; }
1737
- function stripBOM(s) { return (s && s.charCodeAt(0) === 0xFEFF) ? s.slice(1) : s; }
1738
-
1739
- function detectFromFirstLine(raw, rid) {
1740
- if (!raw) return { lang: '', content: raw, isOutput: false };
1741
- const lines = raw.split(/\r?\n/);
1742
- if (!lines.length) return { lang: '', content: raw, isOutput: false };
1743
- let i = 0; while (i < lines.length && !lines[i].trim()) i++;
1744
- if (i >= lines.length) { log(`#${rid} first-line: only whitespace`); return { lang: '', content: raw, isOutput: false }; }
1745
- let first = stripBOM(lines[i]).trim();
1746
- first = first.replace(/^\s*lang(?:uage)?\s*[:=]\s*/i, '').trim();
1747
- let token = first.split(/\s+/)[0].replace(/:$/, '');
1748
- if (!/^[A-Za-z][\w#+\-\.]{0,30}$/.test(token)) { log(`#${rid} first-line: no token match`, { first }); return { lang: '', content: raw, isOutput: false }; }
1749
- let cand = normLang(token);
1750
- if (cand === 'output') {
1751
- const content = lines.slice(i + 1).join('\n');
1752
- log(`#${rid} first-line: output header`);
1753
- return { lang: 'python', headerLabel: 'output', content, isOutput: true };
2217
+ // precompute code meta
2218
+ const content = res.content || '';
2219
+ const len = content.length;
2220
+ const head = content.slice(0, 64);
2221
+ const tail = content.slice(-64);
2222
+ const headEsc = Utils.escapeHtml(head);
2223
+ const tailEsc = Utils.escapeHtml(tail);
2224
+ const nl = Utils.countNewlines(content);
2225
+
2226
+ const inner = Utils.escapeHtml(content);
2227
+ const idxLocal = CODE_IDX++;
2228
+
2229
+ let actions = '';
2230
+ if (langClass === 'html') {
2231
+ actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-preview"><img src="${cfg.ICONS.CODE_PREVIEW}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.PREVIEW)}</span></a>`;
2232
+ } else if (langClass === 'python' && headerLabel !== 'output') {
2233
+ actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-run"><img src="${cfg.ICONS.CODE_RUN}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.RUN)}</span></a>`;
2234
+ }
2235
+ actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-collapse"><img src="${cfg.ICONS.CODE_MENU}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.COLLAPSE)}</span></a>`;
2236
+ actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-copy"><img src="${cfg.ICONS.CODE_COPY}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.COPY)}</span></a>`;
2237
+
2238
+ return (
2239
+ `<div class="code-wrapper highlight" data-index="${idxLocal}"` +
2240
+ ` data-code-lang="${Utils.escapeHtml(res.lang || '')}"` +
2241
+ ` data-code-len="${String(len)}" data-code-head="${headEsc}" data-code-tail="${tailEsc}" data-code-nl="${String(nl)}"` +
2242
+ ` data-locale-collapse="${Utils.escapeHtml(cfg.LOCALE.COLLAPSE)}" data-locale-expand="${Utils.escapeHtml(cfg.LOCALE.EXPAND)}"` +
2243
+ ` data-locale-copy="${Utils.escapeHtml(cfg.LOCALE.COPY)}" data-locale-copied="${Utils.escapeHtml(cfg.LOCALE.COPIED)}" data-style="${Utils.escapeHtml(cfg.CODE_STYLE)}">` +
2244
+ `<p class="code-header-wrapper"><span><span class="code-header-lang">${Utils.escapeHtml(headerLabel)} </span>${actions}</span></p>` +
2245
+ `<pre><code class="language-${Utils.escapeHtml(langClass)} hljs">${inner}</code></pre>` +
2246
+ `</div>`
2247
+ );
1754
2248
  }
1755
- const rest = lines.slice(i + 1).join('\n');
1756
- if (!rest.trim()) { log(`#${rid} first-line: directive but no content after, ignore`, { cand }); return { lang: '', content: raw, isOutput: false }; }
1757
- log(`#${rid} first-line: directive accepted`, { cand, restLen: rest.length, hljs: isSupportedByHLJS(cand) });
1758
- return { lang: cand, headerLabel: cand, content: rest, isOutput: false };
1759
- }
2249
+ })(this.MD, this.logger);
2250
+ }
2251
+ // Replace "sandbox:" links with file:// in markdown source (host policy).
2252
+ preprocessMD(s) { return (s || '').replace(/\]\(sandbox:/g, '](file://'); }
2253
+ // Decode base64 UTF-8 to string (shared TextDecoder).
2254
+ b64ToUtf8(b64) {
2255
+ const bin = atob(b64);
2256
+ const bytes = new Uint8Array(bin.length);
2257
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
2258
+ return Utils.utf8Decode(bytes);
2259
+ }
1760
2260
 
1761
- md.renderer.rules.fence = (tokens, idx) => renderFence(tokens[idx]);
1762
- md.renderer.rules.code_block = (tokens, idx) => renderFence({ info: '', content: tokens[idx].content || '' });
2261
+ // Apply custom markup for bot messages only (method name kept for API).
2262
+ applyCustomMarkupForBots(root) {
2263
+ const MD = this.MD;
2264
+ try {
2265
+ const scope = root || document;
2266
+ const targets = [];
1763
2267
 
1764
- function resolveLanguageAndContent(info, raw, rid) {
1765
- const infoLangRaw = (info || '').trim().split(/\s+/)[0] || '';
1766
- let cand = normLang(infoLangRaw);
1767
- if (cand === 'output') {
1768
- log(`#${rid} info: output header`);
1769
- return { lang: 'python', headerLabel: 'output', content: raw, isOutput: true };
2268
+ // If scope itself is a bot message box
2269
+ if (scope && scope.nodeType === 1 && scope.classList && scope.classList.contains('msg-box') &&
2270
+ scope.classList.contains('msg-bot')) {
2271
+ targets.push(scope);
1770
2272
  }
1771
- if (cand) {
1772
- log(`#${rid} info: token`, { infoLangRaw, cand, hljs: isSupportedByHLJS(cand) });
1773
- return { lang: cand, headerLabel: cand, content: raw, isOutput: false };
1774
- }
1775
- const det = detectFromFirstLine(raw, rid);
1776
- if (det && (det.lang || det.isOutput)) return det;
1777
- log(`#${rid} resolve: fallback`);
1778
- return { lang: '', headerLabel: 'code', content: raw, isOutput: false };
1779
- }
1780
2273
 
1781
- function renderFence(token) {
1782
- const raw = token.content || '';
1783
- const rid = String(CODE_IDX + '');
1784
- const fp = makeFP(token.info || '', raw);
1785
- const canLog = !DEDUP || !seenFP.has(fp);
1786
- if (canLog) log(`FENCE_ENTER #${rid}`, { info: (token.info || ''), rawHead: logger.pv(raw) });
1787
-
1788
- const res = resolveLanguageAndContent(token.info || '', raw, rid);
1789
- const isOutput = !!res.isOutput;
1790
- const headerLabel = isOutput ? 'output' : (res.headerLabel || (res.lang || 'code'));
1791
- const langClass = isOutput ? 'python' : classForHighlight(res.lang);
1792
-
1793
- if (canLog) {
1794
- log(`FENCE_RESOLVE #${rid}`, { headerLabel, langToken: (res.lang || ''), langClass, hljsSupported: isSupportedByHLJS(res.lang || ''), contentLen: (res.content || '').length });
1795
- if (DEDUP) seenFP.add(fp);
2274
+ // Collect bot message boxes within the scope
2275
+ if (scope && typeof scope.querySelectorAll === 'function') {
2276
+ const list = scope.querySelectorAll('.msg-box.msg-bot');
2277
+ for (let i = 0; i < list.length; i++) targets.push(list[i]);
1796
2278
  }
1797
2279
 
1798
- // precompute code meta
1799
- const content = res.content || '';
1800
- const len = content.length;
1801
- const head = content.slice(0, 64);
1802
- const tail = content.slice(-64);
1803
- const headEsc = Utils.escapeHtml(head);
1804
- const tailEsc = Utils.escapeHtml(tail);
1805
- const nl = Utils.countNewlines(content);
1806
-
1807
- const inner = Utils.escapeHtml(content);
1808
- const idxLocal = CODE_IDX++;
1809
-
1810
- let actions = '';
1811
- if (langClass === 'html') {
1812
- actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-preview"><img src="${cfg.ICONS.CODE_PREVIEW}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.PREVIEW)}</span></a>`;
1813
- } else if (langClass === 'python' && headerLabel !== 'output') {
1814
- actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-run"><img src="${cfg.ICONS.CODE_RUN}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.RUN)}</span></a>`;
2280
+ // If scope is inside a bot message, include the closest ancestor as well
2281
+ if (scope && scope.nodeType === 1 && typeof scope.closest === 'function') {
2282
+ const closestMsg = scope.closest('.msg-box.msg-bot');
2283
+ if (closestMsg) targets.push(closestMsg);
1815
2284
  }
1816
- actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-collapse"><img src="${cfg.ICONS.CODE_MENU}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.COLLAPSE)}</span></a>`;
1817
- actions += `<a href="empty:${idxLocal}" class="code-header-action code-header-copy"><img src="${cfg.ICONS.CODE_COPY}" class="action-img" data-id="${idxLocal}"><span>${Utils.escapeHtml(cfg.LOCALE.COPY)}</span></a>`;
1818
-
1819
- return (
1820
- `<div class="code-wrapper highlight" data-index="${idxLocal}"` +
1821
- ` data-code-lang="${Utils.escapeHtml(res.lang || '')}"` +
1822
- ` data-code-len="${String(len)}" data-code-head="${headEsc}" data-code-tail="${tailEsc}" data-code-nl="${String(nl)}"` + // include nl for full renderer
1823
- ` data-locale-collapse="${Utils.escapeHtml(cfg.LOCALE.COLLAPSE)}" data-locale-expand="${Utils.escapeHtml(cfg.LOCALE.EXPAND)}"` +
1824
- ` data-locale-copy="${Utils.escapeHtml(cfg.LOCALE.COPY)}" data-locale-copied="${Utils.escapeHtml(cfg.LOCALE.COPIED)}" data-style="${Utils.escapeHtml(cfg.CODE_STYLE)}">` +
1825
- `<p class="code-header-wrapper"><span><span class="code-header-lang">${Utils.escapeHtml(headerLabel)} </span>${actions}</span></p>` +
1826
- `<pre><code class="language-${Utils.escapeHtml(langClass)} hljs">${inner}</code></pre>` +
1827
- `</div>`
1828
- );
1829
- }
1830
- })(this.MD, this.logger);
1831
- }
1832
- // Replace "sandbox:" links with file:// in markdown source (host policy).
1833
- preprocessMD(s) { return (s || '').replace(/\]\(sandbox:/g, '](file://'); }
1834
- // Decode base64 UTF-8 to string (shared TextDecoder).
1835
- b64ToUtf8(b64) {
1836
- const bin = atob(b64);
1837
- const bytes = new Uint8Array(bin.length);
1838
- for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1839
- return Utils.utf8Decode(bytes);
1840
- }
1841
-
1842
- // Apply custom markup for bot messages only (method name kept for API).
1843
- applyCustomMarkupForBots(root) {
1844
- const MD = this.MD;
1845
- try {
1846
- const scope = root || document;
1847
- const targets = [];
1848
2285
 
1849
- // If scope itself is a bot message box
1850
- if (scope && scope.nodeType === 1 && scope.classList && scope.classList.contains('msg-box') &&
1851
- scope.classList.contains('msg-bot')) {
1852
- targets.push(scope);
2286
+ // Deduplicate and apply rules only to bot messages
2287
+ const seen = new Set();
2288
+ for (const el of targets) {
2289
+ if (!el || !el.isConnected || seen.has(el)) continue;
2290
+ seen.add(el);
2291
+ this.customMarkup.apply(el, MD);
2292
+ }
2293
+ } catch (_) {
2294
+ // Keep render path resilient
1853
2295
  }
2296
+ }
1854
2297
 
1855
- // Collect bot message boxes within the scope
1856
- if (scope && typeof scope.querySelectorAll === 'function') {
1857
- const list = scope.querySelectorAll('.msg-box.msg-bot');
1858
- for (let i = 0; i < list.length; i++) targets.push(list[i]);
1859
- }
2298
+ // Helper: choose renderer (hot vs full) for snapshot use.
2299
+ _md(streamingHint) {
2300
+ return streamingHint ? (this.MD_STREAM || this.MD) : (this.MD || this.MD_STREAM);
2301
+ }
1860
2302
 
1861
- // If scope is inside a bot message, include the closest ancestor as well
1862
- if (scope && scope.nodeType === 1 && typeof scope.closest === 'function') {
1863
- const closestMsg = scope.closest('.msg-box.msg-bot');
1864
- if (closestMsg) targets.push(closestMsg);
1865
- }
2303
+ // Async, batched processing of [data-md64] / [md-block-markdown] to keep UI responsive on heavy loads.
2304
+ // Note: user messages are rendered as plain text (no markdown-it, no custom markup, no KaTeX).
2305
+ async renderPendingMarkdown(root) {
2306
+ const MD = this.MD; if (!MD) return;
2307
+ const scope = root || document;
1866
2308
 
1867
- // Deduplicate and apply rules only to bot messages
1868
- const seen = new Set();
1869
- for (const el of targets) {
1870
- if (!el || !el.isConnected || seen.has(el)) continue;
1871
- seen.add(el);
1872
- this.customMarkup.apply(el, MD);
1873
- }
1874
- } catch (_) {
1875
- // Keep render path resilient
1876
- }
1877
- }
2309
+ // Collect both legacy base64 holders and new native Markdown holders
2310
+ const nodes = Array.from(scope.querySelectorAll('[data-md64], [md-block-markdown]'));
2311
+ if (nodes.length === 0) {
2312
+ // Nothing to materialize right now. Avoid arming rAF work unless there is
2313
+ // actually something present that needs highlight/scroll/math.
2314
+ try {
2315
+ const hasBots = !!(scope && scope.querySelector && scope.querySelector('.msg-box.msg-bot'));
2316
+ const hasWrappers = !!(scope && scope.querySelector && scope.querySelector('.code-wrapper'));
2317
+ const hasCodes = !!(scope && scope.querySelector && scope.querySelector('.msg-box.msg-bot pre code'));
2318
+ const hasUnhighlighted = !!(scope && scope.querySelector && scope.querySelector('.msg-box.msg-bot pre code:not([data-highlighted="yes"])'));
2319
+ const hasMath = !!(scope && scope.querySelector && scope.querySelector('script[type^="math/tex"]'));
1878
2320
 
1879
- // Helper: choose renderer (hot vs full) for snapshot use.
1880
- _md(streamingHint) {
1881
- return streamingHint ? (this.MD_STREAM || this.MD) : (this.MD || this.MD_STREAM);
1882
- }
2321
+ // Apply Custom Markup only if bot messages are present.
2322
+ if (hasBots) { this.applyCustomMarkupForBots(scope); }
1883
2323
 
1884
- // Async, batched processing of [data-md64] / [md-block-markdown] to keep UI responsive on heavy loads.
1885
- // Note: user messages are rendered as plain text (no markdown-it, no custom markup, no KaTeX).
1886
- async renderPendingMarkdown(root) {
1887
- const MD = this.MD; if (!MD) return;
1888
- const scope = root || document;
2324
+ // Restore collapsed state only if we can actually find wrappers.
2325
+ if (hasWrappers) { this.restoreCollapsedCode(scope); }
1889
2326
 
1890
- // Collect both legacy base64 holders and new native Markdown holders
1891
- const nodes = Array.from(scope.querySelectorAll('[data-md64], [md-block-markdown]'));
1892
- if (nodes.length === 0) {
1893
- // Nothing to materialize right now. Avoid arming rAF work unless there is
1894
- // actually something present that needs highlight/scroll/math.
1895
- try {
1896
- const hasBots = !!(scope && scope.querySelector && scope.querySelector('.msg-box.msg-bot'));
1897
- const hasWrappers = !!(scope && scope.querySelector && scope.querySelector('.code-wrapper'));
1898
- const hasCodes = !!(scope && scope.querySelector && scope.querySelector('.msg-box.msg-bot pre code'));
1899
- const hasUnhighlighted = !!(scope && scope.querySelector && scope.querySelector('.msg-box.msg-bot pre code:not([data-highlighted="yes"])'));
1900
- const hasMath = !!(scope && scope.querySelector && scope.querySelector('script[type^="math/tex"]'));
2327
+ // Initialize code scroll helpers for current root.
2328
+ this.hooks.codeScrollInit(scope);
1901
2329
 
1902
- // Apply Custom Markup only if bot messages are present.
1903
- if (hasBots) { this.applyCustomMarkupForBots(scope); }
2330
+ // Init code-scroll/highlight observers only when there are codes in DOM.
2331
+ if (hasCodes) {
2332
+ this.hooks.observeMsgBoxes(scope);
2333
+ this.hooks.observeNewCode(scope, {
2334
+ deferLastIfStreaming: true,
2335
+ minLinesForLast: this.cfg.PROFILE_CODE.minLinesForHL,
2336
+ minCharsForLast: this.cfg.PROFILE_CODE.minCharsForHL
2337
+ });
2338
+ if (hasUnhighlighted && typeof runtime !== 'undefined' && runtime.highlighter) {
2339
+ runtime.highlighter.scanVisibleCodesInRoot(scope, runtime.stream.activeCode || null);
2340
+ }
2341
+ }
1904
2342
 
1905
- // Restore collapsed state only if we can actually find wrappers.
1906
- if (hasWrappers) { this.restoreCollapsedCode(scope); }
2343
+ // Schedule KaTeX render only if there are math scripts present.
2344
+ if (hasMath) { this.hooks.scheduleMathRender(scope); }
2345
+ this.hooks.codeScrollInit(scope);
1907
2346
 
1908
- // Initialize code scroll helpers for current root.
1909
- this.hooks.codeScrollInit(scope);
2347
+ } catch (_) { /* swallow: keep idle path safe */ }
1910
2348
 
1911
- // Init code-scroll/highlight observers only when there are codes in DOM.
1912
- if (hasCodes) {
1913
- this.hooks.observeMsgBoxes(scope);
1914
- this.hooks.observeNewCode(scope, {
1915
- deferLastIfStreaming: true,
1916
- minLinesForLast: this.cfg.PROFILE_CODE.minLinesForHL,
1917
- minCharsForLast: this.cfg.PROFILE_CODE.minCharsForHL
1918
- });
1919
- if (hasUnhighlighted && typeof runtime !== 'undefined' && runtime.highlighter) {
1920
- runtime.highlighter.scanVisibleCodesInRoot(scope, runtime.stream.activeCode || null);
1921
- }
1922
- }
2349
+ return;
2350
+ }
1923
2351
 
1924
- // Schedule KaTeX render only if there are math scripts present.
1925
- if (hasMath) { this.hooks.scheduleMathRender(scope); }
1926
- this.hooks.codeScrollInit(scope);
2352
+ // Track which bot message boxes actually changed to avoid a heavy global Custom Markup pass.
2353
+ const touchedBoxes = new Set();
1927
2354
 
1928
- } catch (_) { /* swallow: keep idle path safe */ }
2355
+ // Budgeted, cooperative loop: process nodes one-by-one with per-frame yield when needed.
2356
+ const perSlice = (this.cfg.ASYNC && this.cfg.ASYNC.MD_NODES_PER_SLICE) || 12; // upper bound per frame
2357
+ let sliceCount = 0;
2358
+ let startedAt = Utils.now();
1929
2359
 
1930
- return;
1931
- }
2360
+ for (let j = 0; j < nodes.length; j++) {
2361
+ const el = nodes[j];
2362
+ if (!el || !el.isConnected) continue;
1932
2363
 
1933
- // Track which bot message boxes actually changed to avoid a heavy global Custom Markup pass.
1934
- const touchedBoxes = new Set();
2364
+ let md = '';
2365
+ const isNative = el.hasAttribute('md-block-markdown');
2366
+ const msgBox = (el.closest && el.closest('.msg-box.msg-bot, .msg-box.msg-user')) || null;
2367
+ const isUserMsg = !!(msgBox && msgBox.classList.contains('msg-user'));
2368
+ const isBotMsg = !!(msgBox && msgBox.classList.contains('msg-bot'));
1935
2369
 
1936
- // Budgeted, cooperative loop: process nodes one-by-one with per-frame yield when needed.
1937
- const perSlice = (this.cfg.ASYNC && this.cfg.ASYNC.MD_NODES_PER_SLICE) || 12; // upper bound per frame
1938
- let sliceCount = 0;
1939
- let startedAt = Utils.now();
2370
+ // Read source text (do not preprocess for user messages to keep it raw)
2371
+ if (isNative) {
2372
+ try { md = isUserMsg ? (el.textContent || '') : this.preprocessMD(el.textContent || ''); } catch (_) { md = ''; }
2373
+ try { el.removeAttribute('md-block-markdown'); } catch (_) {}
2374
+ } else {
2375
+ const b64 = el.getAttribute('data-md64'); if (!b64) continue;
2376
+ try { md = this.b64ToUtf8(b64); } catch (_) { md = ''; }
2377
+ el.removeAttribute('data-md64');
2378
+ if (!isUserMsg) { try { md = this.preprocessMD(md); } catch (_) {} }
2379
+ }
1940
2380
 
1941
- for (let j = 0; j < nodes.length; j++) {
1942
- const el = nodes[j];
1943
- if (!el || !el.isConnected) continue;
2381
+ if (isUserMsg) {
2382
+ // User message: replace placeholder with raw plain text only.
2383
+ const span = document.createElement('span');
2384
+ span.textContent = md;
2385
+ el.replaceWith(span);
2386
+ // Intentionally do NOT add to touchedBoxes; no Custom Markup for user.
2387
+ } else if (isBotMsg) {
2388
+ // Bot message: full markdown-it render with Custom Markup.
2389
+ let html = '';
2390
+ try {
2391
+ let src = md;
2392
+ // Pre-md transforms for source-phase rules
2393
+ if (this.customMarkup && typeof this.customMarkup.transformSource === 'function') {
2394
+ src = this.customMarkup.transformSource(src, { streaming: false });
2395
+ }
2396
+ html = MD.render(src);
2397
+ } catch (_) { html = Utils.escapeHtml(md); }
2398
+
2399
+ // build fragment directly (avoid intermediate container allocations).
2400
+ let frag = null;
2401
+ try {
2402
+ const range = document.createRange();
2403
+ const ctx = el.parentNode || document.body || document.documentElement;
2404
+ range.selectNode(ctx);
2405
+ frag = range.createContextualFragment(html);
2406
+ } catch (_) {
2407
+ const tmp = document.createElement('div');
2408
+ tmp.innerHTML = html;
2409
+ frag = document.createDocumentFragment();
2410
+ while (tmp.firstChild) frag.appendChild(tmp.firstChild);
2411
+ }
1944
2412
 
1945
- let md = '';
1946
- const isNative = el.hasAttribute('md-block-markdown');
1947
- const msgBox = (el.closest && el.closest('.msg-box.msg-bot, .msg-box.msg-user')) || null;
1948
- const isUserMsg = !!(msgBox && msgBox.classList.contains('msg-user'));
1949
- const isBotMsg = !!(msgBox && msgBox.classList.contains('msg-bot'));
2413
+ // Apply Custom Markup on a lightweight DocumentFragment
2414
+ try { this.customMarkup.apply(frag, MD); } catch (_) {}
1950
2415
 
1951
- // Read source text (do not preprocess for user messages to keep it raw)
1952
- if (isNative) {
1953
- try { md = isUserMsg ? (el.textContent || '') : this.preprocessMD(el.textContent || ''); } catch (_) { md = ''; }
1954
- try { el.removeAttribute('md-block-markdown'); } catch (_) {}
1955
- } else {
1956
- const b64 = el.getAttribute('data-md64'); if (!b64) continue;
1957
- try { md = this.b64ToUtf8(b64); } catch (_) { md = ''; }
1958
- el.removeAttribute('data-md64');
1959
- if (!isUserMsg) { try { md = this.preprocessMD(md); } catch (_) {} }
1960
- }
2416
+ el.replaceWith(frag);
2417
+ touchedBoxes.add(msgBox);
2418
+ } else {
2419
+ // Outside of any message box: materialize as plain text.
2420
+ const span = document.createElement('span');
2421
+ span.textContent = md;
2422
+ el.replaceWith(span);
2423
+ }
1961
2424
 
1962
- if (isUserMsg) {
1963
- // User message: replace placeholder with raw plain text only.
1964
- const span = document.createElement('span');
1965
- span.textContent = md;
1966
- el.replaceWith(span);
1967
- // Intentionally do NOT add to touchedBoxes; no Custom Markup for user.
1968
- } else if (isBotMsg) {
1969
- // Bot message: full markdown-it render with Custom Markup.
1970
- let html = '';
1971
- try { html = MD.render(md); } catch (_) { html = Utils.escapeHtml(md); }
1972
-
1973
- // build fragment directly (avoid intermediate container allocations).
1974
- let frag = null;
1975
- try {
1976
- const range = document.createRange();
1977
- const ctx = el.parentNode || document.body || document.documentElement;
1978
- range.selectNode(ctx);
1979
- frag = range.createContextualFragment(html);
1980
- } catch (_) {
1981
- const tmp = document.createElement('div');
1982
- tmp.innerHTML = html;
1983
- frag = document.createDocumentFragment();
1984
- while (tmp.firstChild) frag.appendChild(tmp.firstChild);
2425
+ sliceCount++;
2426
+ // Yield by time budget or by count to keep frame short and reactive.
2427
+ if (sliceCount >= perSlice || this.asyncer.shouldYield(startedAt)) {
2428
+ await this.asyncer.yield();
2429
+ startedAt = Utils.now();
2430
+ sliceCount = 0;
1985
2431
  }
2432
+ }
1986
2433
 
1987
- // Apply Custom Markup on a lightweight DocumentFragment
1988
- try { this.customMarkup.apply(frag, MD); } catch (_) {}
2434
+ // Apply Custom Markup only to actually modified BOT messages (keeps this pass light).
2435
+ try {
2436
+ touchedBoxes.forEach(box => { try { this.customMarkup.apply(box, MD); } catch (_) {} });
2437
+ } catch (_) {}
1989
2438
 
1990
- el.replaceWith(frag);
1991
- touchedBoxes.add(msgBox);
1992
- } else {
1993
- // Outside of any message box: materialize as plain text.
1994
- const span = document.createElement('span');
1995
- span.textContent = md;
1996
- el.replaceWith(span);
1997
- }
2439
+ // Same post-processing as before (idempotent with external calls).
2440
+ this.restoreCollapsedCode(scope);
2441
+ this.hooks.observeNewCode(scope, {
2442
+ deferLastIfStreaming: true,
2443
+ minLinesForLast: this.cfg.PROFILE_CODE.minLinesForHL,
2444
+ minCharsForLast: this.cfg.PROFILE_CODE.minCharsForHL
2445
+ });
2446
+ this.hooks.observeMsgBoxes(scope);
2447
+ this.hooks.scheduleMathRender(scope);
2448
+ this.hooks.codeScrollInit(scope);
1998
2449
 
1999
- sliceCount++;
2000
- // Yield by time budget or by count to keep frame short and reactive.
2001
- if (sliceCount >= perSlice || this.asyncer.shouldYield(startedAt)) {
2002
- await this.asyncer.yield();
2003
- startedAt = Utils.now();
2004
- sliceCount = 0;
2450
+ if (typeof runtime !== 'undefined' && runtime.highlighter) {
2451
+ runtime.highlighter.scanVisibleCodesInRoot(scope, runtime.stream.activeCode || null);
2005
2452
  }
2006
2453
  }
2007
2454
 
2008
- // Apply Custom Markup only to actually modified BOT messages (keeps this pass light).
2009
- try {
2010
- touchedBoxes.forEach(box => { try { this.customMarkup.apply(box, MD); } catch (_) {} });
2011
- } catch (_) {}
2012
-
2013
- // Same post-processing as before (idempotent with external calls).
2014
- this.restoreCollapsedCode(scope);
2015
- this.hooks.observeNewCode(scope, {
2016
- deferLastIfStreaming: true,
2017
- minLinesForLast: this.cfg.PROFILE_CODE.minLinesForHL,
2018
- minCharsForLast: this.cfg.PROFILE_CODE.minCharsForHL
2019
- });
2020
- this.hooks.observeMsgBoxes(scope);
2021
- this.hooks.scheduleMathRender(scope);
2022
- this.hooks.codeScrollInit(scope);
2023
-
2024
- if (typeof runtime !== 'undefined' && runtime.highlighter) {
2025
- runtime.highlighter.scanVisibleCodesInRoot(scope, runtime.stream.activeCode || null);
2455
+ // Render streaming snapshot.
2456
+ renderStreamingSnapshot(src) {
2457
+ const md = this._md(true);
2458
+ if (!md) return '';
2459
+ try {
2460
+ let s = String(src || '');
2461
+ // Pre-markdown custom transforms (e.g. [!exec]/<execute> => ```python fences)
2462
+ if (this.customMarkup && typeof this.customMarkup.transformSource === 'function') {
2463
+ s = this.customMarkup.transformSource(s, { streaming: true });
2464
+ }
2465
+ return md.render(s);
2466
+ } catch (_) { return Utils.escapeHtml(src); }
2026
2467
  }
2027
- }
2028
2468
 
2029
- // Render streaming snapshot (reduced features).
2030
- renderStreamingSnapshot(src) {
2031
- const md = this._md(true);
2032
- if (!md) return '';
2033
- try { return md.render(src); } catch (_) { return Utils.escapeHtml(src); }
2034
- }
2035
- // Render final snapshot (full features).
2036
- renderFinalSnapshot(src) {
2037
- const md = this._md(false);
2038
- if (!md) return '';
2039
- try { return md.render(src); } catch (_) { return Utils.escapeHtml(src); }
2040
- }
2469
+ renderFinalSnapshot(src) {
2470
+ const md = this._md(false);
2471
+ if (!md) return '';
2472
+ try {
2473
+ let s = String(src || '');
2474
+ if (this.customMarkup && typeof this.customMarkup.transformSource === 'function') {
2475
+ s = this.customMarkup.transformSource(s, { streaming: false });
2476
+ }
2477
+ return md.render(s);
2478
+ } catch (_) { return Utils.escapeHtml(src); }
2479
+ }
2041
2480
 
2042
- // Restore collapse/expand state of code blocks after DOM updates.
2043
- restoreCollapsedCode(root) {
2044
- const scope = root || document;
2045
- const wrappers = scope.querySelectorAll('.code-wrapper');
2046
- wrappers.forEach((wrapper) => {
2047
- const index = wrapper.getAttribute('data-index');
2048
- const localeCollapse = wrapper.getAttribute('data-locale-collapse');
2049
- const localeExpand = wrapper.getAttribute('data-locale-expand');
2050
- const source = wrapper.querySelector('code');
2051
- const isCollapsed = (window.__collapsed_idx || []).includes(index);
2052
- if (!source) return;
2053
- const btn = wrapper.querySelector('.code-header-collapse');
2054
- if (isCollapsed) {
2055
- source.style.display = 'none';
2056
- if (btn) { const span = btn.querySelector('span'); if (span) span.textContent = localeExpand; }
2057
- } else {
2058
- source.style.display = 'block';
2059
- if (btn) { const span = btn.querySelector('span'); if (span) span.textContent = localeCollapse; }
2060
- }
2061
- });
2481
+ // Restore collapse/expand state of code blocks after DOM updates.
2482
+ restoreCollapsedCode(root) {
2483
+ const scope = root || document;
2484
+ const wrappers = scope.querySelectorAll('.code-wrapper');
2485
+ wrappers.forEach((wrapper) => {
2486
+ const index = wrapper.getAttribute('data-index');
2487
+ const localeCollapse = wrapper.getAttribute('data-locale-collapse');
2488
+ const localeExpand = wrapper.getAttribute('data-locale-expand');
2489
+ const source = wrapper.querySelector('code');
2490
+ const isCollapsed = (window.__collapsed_idx || []).includes(index);
2491
+ if (!source) return;
2492
+ const btn = wrapper.querySelector('.code-header-collapse');
2493
+ if (isCollapsed) {
2494
+ source.style.display = 'none';
2495
+ if (btn) { const span = btn.querySelector('span'); if (span) span.textContent = localeExpand; }
2496
+ } else {
2497
+ source.style.display = 'block';
2498
+ if (btn) { const span = btn.querySelector('span'); if (span) span.textContent = localeCollapse; }
2499
+ }
2500
+ });
2501
+ }
2062
2502
  }
2063
- }
2064
2503
  window.__collapsed_idx = window.__collapsed_idx || [];
2065
2504
 
2066
2505
  // ==========================================================================
@@ -2596,6 +3035,11 @@
2596
3035
  img.className = 'uc-toggle-icon';
2597
3036
  img.alt = labels.expand;
2598
3037
  img.src = icons.expand;
3038
+
3039
+ // Provide a sane default size even if CSS did not load yet (CSS will override when present).
3040
+ img.width = 26; // keep in sync with CSS fallback var(--uc-toggle-icon-size, 26px)
3041
+ img.height = 26; // ensures a consistent, non-tiny control from the first paint
3042
+
2599
3043
  toggle.appendChild(img);
2600
3044
 
2601
3045
  // Attach local listeners (no global handler change; production-safe).
@@ -2863,14 +3307,32 @@
2863
3307
  } catch (_) {}
2864
3308
  }
2865
3309
 
2866
- // Append HTML into message input container.
2867
- appendToInput(content) {
2868
- // Synchronous DOM update – message input must reflect immediately.
2869
- const el = this.dom.get('_append_input_'); if (!el) return;
2870
- el.insertAdjacentHTML('beforeend', content);
2871
- // Apply collapse to any user messages in input area BEFORE the host schedules scroll.
2872
- try { this._userCollapse.apply(el); } catch (_) {}
2873
- }
3310
+ // Append HTML/text into the message input container.
3311
+ // If plain text is provided, wrap it into a minimal msg-user box to keep layout consistent.
3312
+ appendToInput(content) {
3313
+ const el = this.dom.get('_append_input_'); if (!el) return;
3314
+
3315
+ let html = String(content || '');
3316
+ const trimmed = html.trim();
3317
+
3318
+ // If already a full msg-user wrapper, append as-is; otherwise wrap the plain text.
3319
+ const isWrapped = (trimmed.startsWith('<div') && /class=["']msg-box msg-user["']/.test(trimmed));
3320
+ if (!isWrapped) {
3321
+ // Treat incoming payload as plain text (escape + convert newlines to <br>).
3322
+ const safe = (typeof Utils !== 'undefined' && Utils.escapeHtml)
3323
+ ? Utils.escapeHtml(html)
3324
+ : String(html).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
3325
+ const body = safe.replace(/\r?\n/g, '<br>');
3326
+ // Minimal, margin-less user message (no empty msg-extra to avoid extra spacing).
3327
+ html = `<div class="msg-box msg-user"><div class="msg"><p style="margin:0">${body}</p></div></div>`;
3328
+ }
3329
+
3330
+ // Synchronous DOM update.
3331
+ el.insertAdjacentHTML('beforeend', html);
3332
+
3333
+ // Apply collapse to any user messages in input area (now or later).
3334
+ try { this._userCollapse.apply(el); } catch (_) {}
3335
+ }
2874
3336
 
2875
3337
  // Append nodes into messages list and perform post-processing (markdown, code, math).
2876
3338
  appendNode(content, scrollMgr) {
@@ -3050,6 +3512,333 @@
3050
3512
  }
3051
3513
  }
3052
3514
 
3515
+ // ==========================================================================
3516
+ // 9a) Template engine for JSON nodes
3517
+ // ==========================================================================
3518
+
3519
+ class NodeTemplateEngine {
3520
+ // JS-side templates for nodes rendered from JSON payload (RenderBlock).
3521
+ constructor(cfg, logger) {
3522
+ this.cfg = cfg || {};
3523
+ this.logger = logger || { debug: () => {} };
3524
+ }
3525
+
3526
+ _esc(s) { return (s == null) ? '' : String(s); }
3527
+ _escapeHtml(s) { return (typeof Utils !== 'undefined') ? Utils.escapeHtml(s) : String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m])); }
3528
+
3529
+ // Render name header given role
3530
+ _nameHeader(role, name, avatarUrl) {
3531
+ if (!name && !avatarUrl) return '';
3532
+ const cls = (role === 'user') ? 'name-user' : 'name-bot';
3533
+ const img = avatarUrl ? `<img src="${this._esc(avatarUrl)}" class="avatar"> ` : '';
3534
+ return `<div class="name-header ${cls}">${img}${this._esc(name || '')}</div>`;
3535
+ }
3536
+
3537
+ // Render user message block
3538
+ _renderUser(block) {
3539
+ const id = block.id;
3540
+ const inp = block.input || {};
3541
+ const msgId = `msg-user-${id}`;
3542
+
3543
+ // NOTE: timestamps intentionally disabled on frontend
3544
+ // let ts = '';
3545
+ // if (inp.timestamp) { ... }
3546
+
3547
+ const personalize = !!(block && block.extra && block.extra.personalize === true);
3548
+ const nameHeader = personalize ? this._nameHeader('user', inp.name || '', inp.avatar_img || null) : '';
3549
+
3550
+ const content = this._escapeHtml(inp.text || '').replace(/\r?\n/g, '<br>');
3551
+ return `<div class="msg-box msg-user" id="${msgId}">${nameHeader}<div class="msg"><p style="margin:0">${content}</p></div></div>`;
3552
+ }
3553
+
3554
+ // Render extra blocks (images/files/urls/docs/tool-extra)
3555
+ _renderExtras(block) {
3556
+ const parts = [];
3557
+
3558
+ // images
3559
+ const images = block.images || {};
3560
+ const keysI = Object.keys(images);
3561
+ if (keysI.length) {
3562
+ keysI.forEach((k) => {
3563
+ const it = images[k];
3564
+ if (!it) return;
3565
+ const url = this._esc(it.url); const path = this._esc(it.path); const bn = this._esc(it.basename || '');
3566
+ if (it.is_video) {
3567
+ const src = (it.ext === '.webm' || !it.webm_path) ? path : this._esc(it.webm_path);
3568
+ const ext = (src.endsWith('.webm') ? 'webm' : (path.split('.').pop() || 'mp4'));
3569
+ parts.push(
3570
+ `<div class="extra-src-video-box" title="${url}">` +
3571
+ `<video class="video-player" controls>` +
3572
+ `<source src="${src}" type="video/${ext}">` +
3573
+ `</video>` +
3574
+ `<p><a href="bridge://play_video/${url}" class="title">${this._escapeHtml(bn)}</a></p>` +
3575
+ `</div>`
3576
+ );
3577
+ } else {
3578
+ parts.push(
3579
+ `<div class="extra-src-img-box" title="${url}">` +
3580
+ `<div class="img-outer"><div class="img-wrapper"><a href="${url}"><img src="${path}" class="image"></a></div>` +
3581
+ `<a href="${url}" class="title">${this._escapeHtml(bn)}</a></div>` +
3582
+ `</div><br/>`
3583
+ );
3584
+ }
3585
+ });
3586
+ }
3587
+
3588
+ // files
3589
+ const files = block.files || {};
3590
+ const kF = Object.keys(files);
3591
+ if (kF.length) {
3592
+ const rows = [];
3593
+ kF.forEach((k) => {
3594
+ const it = files[k]; if (!it) return;
3595
+ const url = this._esc(it.url); const path = this._esc(it.path);
3596
+ const icon = (typeof window !== 'undefined' && window.ICON_ATTACHMENTS) ? `<img src="${window.ICON_ATTACHMENTS}" class="extra-src-icon">` : '';
3597
+ rows.push(`${icon} <b> [${k}] </b> <a href="${url}">${path}</a>`);
3598
+ });
3599
+ if (rows.length) parts.push(`<div>${rows.join("<br/><br/>")}</div>`);
3600
+ }
3601
+
3602
+ // urls
3603
+ const urls = block.urls || {};
3604
+ const kU = Object.keys(urls);
3605
+ if (kU.length) {
3606
+ const rows = [];
3607
+ kU.forEach((k) => {
3608
+ const it = urls[k]; if (!it) return;
3609
+ const url = this._esc(it.url);
3610
+ const icon = (typeof window !== 'undefined' && window.ICON_URL) ? `<img src="${window.ICON_URL}" class="extra-src-icon">` : '';
3611
+ rows.push(`${icon}<a href="${url}" title="${url}">${url}</a> <small> [${k}] </small>`);
3612
+ });
3613
+ if (rows.length) parts.push(`<div>${rows.join("<br/><br/>")}</div>`);
3614
+ }
3615
+
3616
+ // docs (render on JS) or fallback to docs_html
3617
+ const extra = block.extra || {};
3618
+ const docsRaw = Array.isArray(extra.docs) ? extra.docs : null;
3619
+
3620
+ if (docsRaw && docsRaw.length) {
3621
+ const icon = (typeof window !== 'undefined' && window.ICON_DB) ? `<img src="${window.ICON_DB}" class="extra-src-icon">` : '';
3622
+ const prefix = (typeof window !== 'undefined' && window.LOCALE_DOC_PREFIX) ? String(window.LOCALE_DOC_PREFIX) : 'Doc:';
3623
+ const limit = 3;
3624
+
3625
+ // normalize: [{uuid, meta}] OR [{ uuid: {...} }]
3626
+ const normalized = [];
3627
+ docsRaw.forEach((it) => {
3628
+ if (!it || typeof it !== 'object') return;
3629
+ if ('uuid' in it && 'meta' in it && typeof it.meta === 'object') {
3630
+ normalized.push({ uuid: String(it.uuid), meta: it.meta || {} });
3631
+ } else {
3632
+ const keys = Object.keys(it);
3633
+ if (keys.length === 1) {
3634
+ const uuid = keys[0];
3635
+ const meta = it[uuid];
3636
+ if (meta && typeof meta === 'object') {
3637
+ normalized.push({ uuid: String(uuid), meta });
3638
+ }
3639
+ }
3640
+ }
3641
+ });
3642
+
3643
+ const rows = [];
3644
+ for (let i = 0; i < Math.min(limit, normalized.length); i++) {
3645
+ const d = normalized[i];
3646
+ const meta = d.meta || {};
3647
+ const entries = Object.keys(meta).map(k => `<b>${this._escapeHtml(k)}:</b> ${this._escapeHtml(String(meta[k]))}`).join(', ');
3648
+ rows.push(`<p><small>[${i + 1}] ${this._escapeHtml(d.uuid)}: ${entries}</small></p>`);
3649
+ }
3650
+ if (rows.length) {
3651
+ parts.push(`<p>${icon}<small><b>${this._escapeHtml(prefix)}:</b></small></p>`);
3652
+ parts.push(`<div class="cmd"><p>${rows.join('')}</p></div>`);
3653
+ }
3654
+ } else {
3655
+ // backward compat
3656
+ const docs_html = extra && extra.docs_html ? String(extra.docs_html) : '';
3657
+ if (docs_html) parts.push(docs_html);
3658
+ }
3659
+
3660
+ // plugin-driven tool extra HTML
3661
+ const tool_extra_html = extra && extra.tool_extra_html ? String(extra.tool_extra_html) : '';
3662
+ if (tool_extra_html) parts.push(`<div class="msg-extra">${tool_extra_html}</div>`);
3663
+
3664
+ return parts.join('');
3665
+ }
3666
+
3667
+ // Render message-level actions
3668
+ _renderActions(block) {
3669
+ const extra = block.extra || {};
3670
+ const actions = extra.actions || [];
3671
+ if (!actions || !actions.length) return '';
3672
+ const parts = actions.map((a) => {
3673
+ const href = this._esc(a.href || '#');
3674
+ const title = this._esc(a.title || '');
3675
+ const icon = this._esc(a.icon || '');
3676
+ const id = this._esc(a.id || block.id);
3677
+ return `<a href="${href}" class="action-icon" data-id="${id}" role="button"><span class="cmd"><img src="${icon}" class="action-img" title="${title}" alt="${title}" data-id="${id}"></span></a>`;
3678
+ });
3679
+ return `<div class="action-icons" data-id="${this._esc(block.id)}">${parts.join('')}</div>`;
3680
+ }
3681
+
3682
+ // Render tool output wrapper (always collapsed by default; wrapper visibility depends on flag)
3683
+ // Inside class NodeTemplateEngine
3684
+ _renderToolOutputWrapper(block) {
3685
+ const extra = block.extra || {};
3686
+
3687
+ // IMPORTANT: keep initial tool output verbatim (HTML-ready).
3688
+ // Do NOT HTML-escape here – the host already provides a safe/HTML-ready string.
3689
+ // Escaping again would double-encode entities (e.g. " -> "), which
3690
+ // caused visible """ in the UI instead of quotes.
3691
+ const tool_output_html = (extra.tool_output != null) ? String(extra.tool_output) : '';
3692
+
3693
+ // Wrapper visibility: show/hide based on tool_output_visible...
3694
+ const wrapperDisplay = (extra.tool_output_visible === true) ? '' : 'display:none';
3695
+
3696
+ const toggleTitle = (typeof trans !== 'undefined' && trans) ? trans('action.cmd.expand') : 'Expand';
3697
+ const expIcon = (typeof window !== 'undefined' && window.ICON_EXPAND) ? window.ICON_EXPAND : '';
3698
+
3699
+ return (
3700
+ `<div class='tool-output' style='${wrapperDisplay}'>` +
3701
+ `<span class='toggle-cmd-output' onclick='toggleToolOutput(${this._esc(block.id)});' ` +
3702
+ `title='${this._escapeHtml(toggleTitle)}' role='button'>` +
3703
+ `<img src='${this._esc(expIcon)}' width='25' height='25' valign='middle'>` +
3704
+ `</span>` +
3705
+ // Content is initially collapsed. We intentionally do NOT escape here,
3706
+ // to keep behavior consistent with ToolOutput.append/update (HTML-in).
3707
+ `<div class='content' style='display:none' data-trusted='1'>${tool_output_html}</div>` +
3708
+ `</div>`
3709
+ );
3710
+ }
3711
+
3712
+ // Render bot message block (md-block-markdown)
3713
+ _renderBot(block) {
3714
+ const id = block.id;
3715
+ const out = block.output || {};
3716
+ const msgId = `msg-bot-${id}`;
3717
+
3718
+ // NOTE: timestamps intentionally disabled on frontend
3719
+ // let ts = '';
3720
+ // if (out.timestamp) { ... }
3721
+
3722
+ const personalize = !!(block && block.extra && block.extra.personalize === true);
3723
+ const nameHeader = personalize ? this._nameHeader('bot', out.name || '', out.avatar_img || null) : '';
3724
+
3725
+ const mdText = this._escapeHtml(out.text || '');
3726
+ const toolWrap = this._renderToolOutputWrapper(block);
3727
+ const extras = this._renderExtras(block);
3728
+ const actions = (block.extra && block.extra.footer_icons) ? this._renderActions(block) : '';
3729
+ const debug = (block.extra && block.extra.debug_html) ? String(block.extra.debug_html) : '';
3730
+
3731
+ return (
3732
+ `<div class='msg-box msg-bot' id='${msgId}'>` +
3733
+ `${nameHeader}` +
3734
+ `<div class='msg'>` +
3735
+ `<div class='md-block' md-block-markdown='1'>${mdText}</div>` +
3736
+ `<div class='msg-tool-extra'></div>` +
3737
+ `${toolWrap}` +
3738
+ `<div class='msg-extra'>${extras}</div>` +
3739
+ `${actions}${debug}` +
3740
+ `</div>` +
3741
+ `</div>`
3742
+ );
3743
+ }
3744
+
3745
+ // Render one RenderBlock into HTML (may produce 1 or 2 messages – input and/or output)
3746
+ renderNode(block) {
3747
+ const parts = [];
3748
+ if (block && block.input && block.input.text) parts.push(this._renderUser(block));
3749
+ if (block && block.output && block.output.text) parts.push(this._renderBot(block));
3750
+ return parts.join('');
3751
+ }
3752
+
3753
+ // Render array of blocks
3754
+ renderNodes(blocks) {
3755
+ if (!Array.isArray(blocks)) return '';
3756
+ const out = [];
3757
+ for (let i = 0; i < blocks.length; i++) {
3758
+ const b = blocks[i] || null;
3759
+ if (!b) continue;
3760
+ out.push(this.renderNode(b));
3761
+ }
3762
+ return out.join('');
3763
+ }
3764
+ }
3765
+
3766
+ // ==========================================================================
3767
+ // 9b) Data receiver for append/replace nodes
3768
+ // ==========================================================================
3769
+
3770
+ class DataReceiver {
3771
+ // Normalizes payload (HTML string or JSON) and delegates to NodesManager.
3772
+ constructor(cfg, templates, nodes, scrollMgr) {
3773
+ this.cfg = cfg || {};
3774
+ this.templates = templates;
3775
+ this.nodes = nodes;
3776
+ this.scrollMgr = scrollMgr;
3777
+ }
3778
+
3779
+ _tryParseJSON(s) {
3780
+ if (typeof s !== 'string') return s;
3781
+ const t = s.trim();
3782
+ if (!t) return null;
3783
+ // If it's like HTML, don't parse as JSON
3784
+ if (t[0] === '<') return null;
3785
+ try { return JSON.parse(t); } catch (_) { return null; }
3786
+ }
3787
+
3788
+ _normalizeToBlocks(obj) {
3789
+ if (!obj) return [];
3790
+ if (Array.isArray(obj)) return obj;
3791
+ if (obj.node) return [obj.node];
3792
+ if (obj.nodes) return (Array.isArray(obj.nodes) ? obj.nodes : []);
3793
+ // single node-like object
3794
+ if (typeof obj === 'object' && (obj.input || obj.output || obj.id)) return [obj];
3795
+ return [];
3796
+ }
3797
+
3798
+ append(payload) {
3799
+ // Legacy HTML string?
3800
+ if (typeof payload === 'string' && payload.trim().startsWith('<')) {
3801
+ this.nodes.appendNode(payload, this.scrollMgr);
3802
+ return;
3803
+ }
3804
+ // Try JSON
3805
+ const obj = this._tryParseJSON(payload);
3806
+ if (!obj) {
3807
+ // Not JSON – pass through
3808
+ this.nodes.appendNode(String(payload), this.scrollMgr);
3809
+ return;
3810
+ }
3811
+ const blocks = this._normalizeToBlocks(obj);
3812
+ if (!blocks.length) {
3813
+ this.nodes.appendNode('', this.scrollMgr);
3814
+ return;
3815
+ }
3816
+ const html = this.templates.renderNodes(blocks);
3817
+ this.nodes.appendNode(html, this.scrollMgr);
3818
+ }
3819
+
3820
+ replace(payload) {
3821
+ // Legacy HTML string?
3822
+ if (typeof payload === 'string' && payload.trim().startsWith('<')) {
3823
+ this.nodes.replaceNodes(payload, this.scrollMgr);
3824
+ return;
3825
+ }
3826
+ // Try JSON
3827
+ const obj = this._tryParseJSON(payload);
3828
+ if (!obj) {
3829
+ this.nodes.replaceNodes(String(payload), this.scrollMgr);
3830
+ return;
3831
+ }
3832
+ const blocks = this._normalizeToBlocks(obj);
3833
+ if (!blocks.length) {
3834
+ this.nodes.replaceNodes('', this.scrollMgr);
3835
+ return;
3836
+ }
3837
+ const html = this.templates.renderNodes(blocks);
3838
+ this.nodes.replaceNodes(html, this.scrollMgr);
3839
+ }
3840
+ }
3841
+
3053
3842
  // ==========================================================================
3054
3843
  // 10) UI manager
3055
3844
  // ==========================================================================
@@ -3081,7 +3870,10 @@
3081
3870
  '.msg-box.msg-user .msg > .uc-content.uc-collapsed { max-height: 1000px; overflow: hidden; }',
3082
3871
  '.msg-box.msg-user .msg > .uc-toggle { display: none; margin-top: 8px; text-align: center; cursor: pointer; user-select: none; }',
3083
3872
  '.msg-box.msg-user .msg > .uc-toggle.visible { display: block; }',
3084
- '.msg-box.msg-user .msg > .uc-toggle img { width: 20px; height: 20px; opacity: .8; }',
3873
+
3874
+ /* Increased toggle icon size to a comfortable/default size.
3875
+ Overridable via CSS var --uc-toggle-icon-size to keep host-level control. */
3876
+ '.msg-box.msg-user .msg > .uc-toggle img { width: var(--uc-toggle-icon-size, 26px); height: var(--uc-toggle-icon-size, 26px); opacity: .8; }',
3085
3877
  '.msg-box.msg-user .msg > .uc-toggle:hover img { opacity: 1; }'
3086
3878
  ].join('\n');
3087
3879
  document.head.appendChild(style);
@@ -3133,9 +3925,16 @@
3133
3925
  // Tracks whether renderSnapshot injected a one-off synthetic EOL for parsing an open fence
3134
3926
  // (used to strip it from the initial streaming tail to avoid "#\n foo" on first line).
3135
3927
  this._lastInjectedEOL = false;
3928
+
3929
+ this._customFenceSpecs = []; // [{ open, close }, ...]
3930
+ this._fenceCustom = null; // currently active custom fence spec or null
3136
3931
  }
3137
3932
  _d(tag, data) { this.logger.debug('STREAM', tag, data); }
3138
3933
 
3934
+ setCustomFenceSpecs(specs) {
3935
+ this._customFenceSpecs = Array.isArray(specs) ? specs.slice() : [];
3936
+ }
3937
+
3139
3938
  // --- Rope buffer helpers (internal) ---
3140
3939
 
3141
3940
  // Append a chunk into the rope without immediately touching the large string.
@@ -3180,6 +3979,7 @@
3180
3979
 
3181
3980
  // Clear any previous synthetic EOL marker.
3182
3981
  this._lastInjectedEOL = false;
3982
+ this._fenceCustom = null;
3183
3983
 
3184
3984
  this._d('RESET', { });
3185
3985
  }
@@ -3289,11 +4089,13 @@
3289
4089
  if (!atLineStart) { i++; continue; }
3290
4090
  atLineStart = false;
3291
4091
 
4092
+ // Skip list/blockquote/indent normalization (existing logic)
3292
4093
  let j = i;
3293
4094
  while (j < n) {
3294
4095
  let localSpaces = 0;
3295
4096
  while (j < n && (s[j] === ' ' || s[j] === '\t')) { localSpaces += (s[j] === '\t') ? 4 : 1; j++; if (localSpaces > 3) break; }
3296
4097
  if (j < n && s[j] === '>') { j++; if (j < n && s[j] === ' ') j++; continue; }
4098
+
3297
4099
  let saved = j;
3298
4100
  if (j < n && (s[j] === '-' || s[j] === '*' || s[j] === '+')) {
3299
4101
  let jj = j + 1; if (jj < n && s[jj] === ' ') { j = jj + 1; } else { j = saved; }
@@ -3313,6 +4115,47 @@
3313
4115
  }
3314
4116
  if (indent > 3) { i = j; continue; }
3315
4117
 
4118
+ // 1) Custom fences first (e.g. [!exec] ... [/!exec], <execute>...</execute>)
4119
+ if (!this.fenceOpen && this._customFenceSpecs && this._customFenceSpecs.length) {
4120
+ for (let ci = 0; ci < this._customFenceSpecs.length; ci++) {
4121
+ const spec = this._customFenceSpecs[ci];
4122
+ const open = spec && spec.open ? spec.open : '';
4123
+ if (!open) continue;
4124
+ const k = j + open.length;
4125
+ if (k <= n && s.slice(j, k) === open) {
4126
+ if (!inNewOrCrosses(j, k)) { /* seen fully in previous prefix */ }
4127
+ else {
4128
+ this.fenceOpen = true; this._fenceCustom = spec; opened = true; i = k;
4129
+ this._d('FENCE_OPEN_DETECTED_CUSTOM', { open, idxStart: j, idxEnd: k, region: (j >= preLen) ? 'new' : 'cross' });
4130
+ continue; // main while
4131
+ }
4132
+ }
4133
+ }
4134
+ } else if (this.fenceOpen && this._fenceCustom && this._fenceCustom.close) {
4135
+ const close = this._fenceCustom.close;
4136
+ const k = j + close.length;
4137
+ if (k <= n && s.slice(j, k) === close) {
4138
+ // Require only trailing whitespace on the line (consistent with ``` logic)
4139
+ let eol = k; while (eol < n && s[eol] !== '\n' && s[eol] !== '\r') eol++;
4140
+ const onlyWS = this.onlyTrailingWhitespace(s, k, eol);
4141
+ if (onlyWS) {
4142
+ if (!inNewOrCrosses(j, k)) { /* seen in previous prefix */ }
4143
+ else {
4144
+ this.fenceOpen = false; this._fenceCustom = null; closed = true;
4145
+ const endInS = k;
4146
+ const rel = endInS - preLen;
4147
+ splitAt = Math.max(0, Math.min((chunk ? chunk.length : 0), rel));
4148
+ i = k;
4149
+ this._d('FENCE_CLOSE_DETECTED_CUSTOM', { close, idxStart: j, idxEnd: k, splitAt, region: (j >= preLen) ? 'new' : 'cross' });
4150
+ continue; // main while
4151
+ }
4152
+ } else {
4153
+ this._d('FENCE_CLOSE_REJECTED_CUSTOM_NON_WS_AFTER', { close, idxStart: j, idxEnd: k });
4154
+ }
4155
+ }
4156
+ }
4157
+
4158
+ // 2) Standard markdown-it fences (``` or ~~~) – leave your original logic intact
3316
4159
  if (j < n && (s[j] === '`' || s[j] === '~')) {
3317
4160
  const mark = s[j]; let k = j; while (k < n && s[k] === mark) k++; const run = k - j;
3318
4161
 
@@ -3320,10 +4163,10 @@
3320
4163
  if (run >= 3) {
3321
4164
  if (!inNewOrCrosses(j, k)) { i = k; continue; }
3322
4165
  this.fenceOpen = true; this.fenceMark = mark; this.fenceLen = run; opened = true; i = k;
3323
- this._d('FENCE_OPEN_DETECTED', { mark, run, idxStart: j, idxEnd: k, bufTail: this.fenceTail, region: (j >= preLen) ? 'new' : 'cross' });
4166
+ this._d('FENCE_OPEN_DETECTED', { mark, run, idxStart: j, idxEnd: k, region: (j >= preLen) ? 'new' : 'cross' });
3324
4167
  continue;
3325
4168
  }
3326
- } else {
4169
+ } else if (!this._fenceCustom) {
3327
4170
  if (mark === this.fenceMark && run >= this.fenceLen) {
3328
4171
  if (!inNewOrCrosses(j, k)) { i = k; continue; }
3329
4172
  let eol = k; while (eol < n && s[eol] !== '\n' && s[eol] !== '\r') eol++;
@@ -3331,16 +4174,17 @@
3331
4174
  this.fenceOpen = false; closed = true;
3332
4175
  const endInS = k;
3333
4176
  const rel = endInS - preLen;
3334
- const split = Math.max(0, Math.min((chunk ? chunk.length : 0), rel));
3335
- splitAt = split; i = k;
4177
+ splitAt = Math.max(0, Math.min((chunk ? chunk.length : 0), rel));
4178
+ i = k;
3336
4179
  this._d('FENCE_CLOSE_DETECTED', { mark, run, idxStart: j, idxEnd: k, splitAt, region: (j >= preLen) ? 'new' : 'cross' });
3337
4180
  continue;
3338
4181
  } else {
3339
- this._d('FENCE_CLOSE_REJECTED_NON_WS_AFTER', { mark, run, idxStart: j, idxEnd: k, region: (j >= preLen) ? 'new' : (k > preLen ? 'cross' : 'old') });
4182
+ this._d('FENCE_CLOSE_REJECTED_NON_WS_AFTER', { mark, run, idxStart: j, idxEnd: k });
3340
4183
  }
3341
4184
  }
3342
4185
  }
3343
4186
  }
4187
+
3344
4188
  i = j + 1;
3345
4189
  }
3346
4190
 
@@ -3349,6 +4193,7 @@
3349
4193
  this.fenceTail = s.slice(-3);
3350
4194
  return { opened, closed, splitAt };
3351
4195
  }
4196
+
3352
4197
  // Ensure message snapshot container exists.
3353
4198
  getMsgSnapshotRoot(msg) {
3354
4199
  if (!msg) return null;
@@ -3788,6 +4633,48 @@
3788
4633
  this.schedulePromoteTail(true);
3789
4634
  }
3790
4635
  }
4636
+ // Keep language header stable across snapshots for the active streaming code.
4637
+ // If the current snapshot produced a tiny/unsupported token (e.g. 'on', 'ml', 's'),
4638
+ // reuse the last known good language (from previous active state or sticky attribute).
4639
+ stabilizeHeaderLabel(prevAC, newAC) {
4640
+ try {
4641
+ if (!newAC || !newAC.codeEl || !newAC.codeEl.isConnected) return;
4642
+
4643
+ const wrap = newAC.codeEl.closest('.code-wrapper');
4644
+ if (!wrap) return;
4645
+
4646
+ const span = wrap.querySelector('.code-header-lang');
4647
+ const curLabel = (span && span.textContent ? span.textContent.trim() : '').toLowerCase();
4648
+
4649
+ // Do not touch tool/output blocks
4650
+ if (curLabel === 'output') return;
4651
+
4652
+ const tokNow = (wrap.getAttribute('data-code-lang') || '').trim().toLowerCase();
4653
+ const sticky = (wrap.getAttribute('data-lang-sticky') || '').trim().toLowerCase();
4654
+ const prev = (prevAC && prevAC.lang && prevAC.lang !== 'plaintext') ? prevAC.lang.toLowerCase() : '';
4655
+
4656
+ const valid = (t) => !!t && t !== 'plaintext' && this._isHLJSSupported(t);
4657
+
4658
+ let finalTok = '';
4659
+ if (valid(tokNow)) finalTok = tokNow;
4660
+ else if (valid(prev)) finalTok = prev;
4661
+ else if (valid(sticky)) finalTok = sticky;
4662
+
4663
+ if (finalTok) {
4664
+ // Update code class and header label consistently
4665
+ this._updateCodeLangClass(newAC.codeEl, finalTok);
4666
+ this._updateCodeHeaderLabel(newAC.codeEl, finalTok, finalTok);
4667
+ try { wrap.setAttribute('data-code-lang', finalTok); } catch (_) {}
4668
+ try { wrap.setAttribute('data-lang-sticky', finalTok); } catch (_) {}
4669
+ newAC.lang = finalTok; // keep AC state in sync
4670
+ } else {
4671
+ // If current label looks like a tiny/incomplete token, normalize to 'code'
4672
+ if (span && curLabel && curLabel.length < 3) {
4673
+ span.textContent = 'code';
4674
+ }
4675
+ }
4676
+ } catch (_) { /* defensive: never break streaming path */ }
4677
+ }
3791
4678
  // Render a snapshot of current stream buffer into the DOM.
3792
4679
  renderSnapshot(msg) {
3793
4680
  const streaming = !!this.isStreaming;
@@ -3822,13 +4709,21 @@
3822
4709
  range.selectNodeContents(snap);
3823
4710
  frag = range.createContextualFragment(html);
3824
4711
  } catch (_) {
3825
- // Fallback: safe temporary container
3826
4712
  const tmp = document.createElement('div');
3827
4713
  tmp.innerHTML = html;
3828
4714
  frag = document.createDocumentFragment();
3829
4715
  while (tmp.firstChild) frag.appendChild(tmp.firstChild);
3830
4716
  }
3831
4717
 
4718
+ // (stream-aware custom markup):
4719
+ // Apply Custom Markup on the fragment only if at least one rule opted-in for stream.
4720
+ try {
4721
+ if (this.renderer && this.renderer.customMarkup && this.renderer.customMarkup.hasStreamRules()) {
4722
+ const MDinline = this.renderer.MD_STREAM || this.renderer.MD || null;
4723
+ this.renderer.customMarkup.applyStream(frag, MDinline);
4724
+ }
4725
+ } catch (_) { /* keep snapshot path resilient */ }
4726
+
3832
4727
  // Reuse closed, stable code blocks from previous snapshot to avoid re-highlighting
3833
4728
  this.preserveStableClosedCodes(snap, frag, this.fenceOpen === true);
3834
4729
 
@@ -3840,12 +4735,21 @@
3840
4735
  this._ensureBottomForJustFinalized(snap);
3841
4736
 
3842
4737
  // Setup active streaming code if fence is open, otherwise clear active state
3843
- if (this.fenceOpen) {
3844
- const newAC = this.setupActiveCodeFromSnapshot(snap);
3845
- this.activeCode = newAC || null;
3846
- } else {
3847
- this.activeCode = null;
3848
- }
4738
+ const prevAC = this.activeCode; // remember previous active streaming state (if any)
4739
+
4740
+ if (this.fenceOpen) {
4741
+ const newAC = this.setupActiveCodeFromSnapshot(snap);
4742
+
4743
+ // preserve previous frozen/tail state and stable lang/header across snapshots
4744
+ if (prevAC && newAC) {
4745
+ this.rehydrateActiveCode(prevAC, newAC);
4746
+ this.stabilizeHeaderLabel(prevAC, newAC);
4747
+ }
4748
+
4749
+ this.activeCode = newAC || null;
4750
+ } else {
4751
+ this.activeCode = null;
4752
+ }
3849
4753
 
3850
4754
  // Attach scroll/highlight observers (viewport aware)
3851
4755
  if (!this.fenceOpen) {
@@ -3902,6 +4806,7 @@
3902
4806
  const dt = Utils.now() - t0;
3903
4807
  this._d('SNAPSHOT', { fenceOpen: this.fenceOpen, activeCode: !!this.activeCode, bufLen: this.getStreamLength(), timeMs: Math.round(dt), streaming });
3904
4808
  }
4809
+
3905
4810
  // Get current message container (.msg) or create if allowed.
3906
4811
  getMsg(create, name_header) { return this.dom.getStreamMsg(create, name_header); }
3907
4812
  // Start a new streaming session (clear state and display loader, if any).
@@ -4153,20 +5058,17 @@
4153
5058
  this.cfg = cfg; this.logger = logger || new Logger(cfg);
4154
5059
  this.bridge = null; this.connected = false;
4155
5060
  }
4156
- // Low-level log via bridge if available.
4157
5061
  log(text) { try { if (this.bridge && this.bridge.log) this.bridge.log(text); } catch (_) {} }
4158
- // Wire JS callbacks to QWebChannel signals.
4159
5062
  connect(onChunk, onNode, onNodeReplace, onNodeInput) {
4160
5063
  if (!this.bridge) return false; if (this.connected) return true;
4161
5064
  try {
4162
- if (this.bridge.chunk) this.bridge.chunk.connect(onChunk);
5065
+ if (this.bridge.chunk) this.bridge.chunk.connect((name, chunk, type) => onChunk(name, chunk, type));
4163
5066
  if (this.bridge.node) this.bridge.node.connect(onNode);
4164
5067
  if (this.bridge.nodeReplace) this.bridge.nodeReplace.connect(onNodeReplace);
4165
5068
  if (this.bridge.nodeInput) this.bridge.nodeInput.connect(onNodeInput);
4166
5069
  this.connected = true; return true;
4167
5070
  } catch (e) { this.log(e); return false; }
4168
5071
  }
4169
- // Detach callbacks.
4170
5072
  disconnect() {
4171
5073
  if (!this.bridge) return false; if (!this.connected) return true;
4172
5074
  try {
@@ -4177,7 +5079,6 @@
4177
5079
  } catch (_) {}
4178
5080
  this.connected = false; return true;
4179
5081
  }
4180
- // Initialize QWebChannel and notify Python side that JS is ready.
4181
5082
  initQWebChannel(pid, onReady) {
4182
5083
  try {
4183
5084
  new QWebChannel(qt.webChannelTransport, (channel) => {
@@ -4186,9 +5087,8 @@
4186
5087
  onReady && onReady(this.bridge);
4187
5088
  if (this.bridge && this.bridge.js_ready) this.bridge.js_ready(pid);
4188
5089
  });
4189
- } catch (e) { /* swallow: logger will flush when bridge arrives later */ }
5090
+ } catch (e) { /* swallow */ }
4190
5091
  }
4191
- // Convenience wrappers for host actions.
4192
5092
  copyCode(text) { if (this.bridge && this.bridge.copy_text) this.bridge.copy_text(text); }
4193
5093
  previewCode(text) { if (this.bridge && this.bridge.preview_text) this.bridge.preview_text(text); }
4194
5094
  runCode(text) { if (this.bridge && this.bridge.run_text) this.bridge.run_text(text); }
@@ -4417,10 +5317,16 @@
4417
5317
  this.streamQ = new StreamQueue(this.cfg, this.stream, this.scrollMgr, this.raf);
4418
5318
  this.events = new EventManager(this.cfg, this.dom, this.scrollMgr, this.highlighter, this.codeScroll, this.toolOutput, this.bridge);
4419
5319
 
5320
+ try {
5321
+ this.stream.setCustomFenceSpecs(this.customMarkup.getSourceFenceSpecs());
5322
+ } catch (_) {}
5323
+
5324
+ this.templates = new NodeTemplateEngine(this.cfg, this.logger);
5325
+ this.data = new DataReceiver(this.cfg, this.templates, this.nodes, this.scrollMgr);
5326
+
4420
5327
  this.tips = null;
4421
5328
  this._lastHeavyResetMs = 0;
4422
5329
 
4423
- // Bridge hooks between renderer and other subsystems.
4424
5330
  this.renderer.hooks.observeNewCode = (root, opts) => this.highlighter.observeNewCode(root, opts, this.stream.activeCode);
4425
5331
  this.renderer.hooks.observeMsgBoxes = (root) => this.highlighter.observeMsgBoxes(root, (box) => {
4426
5332
  this.highlighter.observeNewCode(box, {
@@ -4437,6 +5343,7 @@
4437
5343
  };
4438
5344
  this.renderer.hooks.codeScrollInit = (root) => this.codeScroll.initScrollableBlocks(root);
4439
5345
  }
5346
+
4440
5347
  // Reset stream state and optionally perform a heavy reset of schedulers and observers.
4441
5348
  resetStreamState(origin, opts) {
4442
5349
  try { this.streamQ.clear(); } catch (_) {}
@@ -4471,6 +5378,17 @@
4471
5378
 
4472
5379
  try { this.tips && this.tips.hide(); } catch (_) {}
4473
5380
  }
5381
+ // API: handle incoming chunk (from bridge).
5382
+ api_onChunk = (name, chunk, type) => {
5383
+ const t = String(type || 'text_delta');
5384
+ if (t === 'text_delta') {
5385
+ this.api_appendStream(name, chunk);
5386
+ return;
5387
+ }
5388
+ // Future-proof: add other chunk types here (attachments, status, etc.)
5389
+ // No-op for unknown types to keep current behavior.
5390
+ this.logger.debug('STREAM', 'IGNORED_NON_TEXT_CHUNK', { type: t, len: (chunk ? String(chunk).length : 0) });
5391
+ };
4474
5392
  // API: begin stream.
4475
5393
  api_beginStream = (chunk = false) => { this.tips && this.tips.hide(); this.resetStreamState('beginStream', { clearMsg: true, finalizeActive: false, forceHeavy: true }); this.stream.beginStream(chunk); };
4476
5394
  // API: end stream.
@@ -4494,12 +5412,27 @@
4494
5412
  // API: clear streaming output area entirely.
4495
5413
  api_clearStream = () => { this.tips && this.tips.hide(); this.resetStreamState('clearStream', { clearMsg: true, forceHeavy: true }); const el = this.dom.getStreamContainer(); if (!el) return; el.replaceChildren(); };
4496
5414
 
4497
- // API: append rendered nodes (messages).
4498
- api_appendNode = (html) => { this.resetStreamState('appendNode'); this.nodes.appendNode(html, this.scrollMgr); };
4499
- // API: replace messages list.
4500
- api_replaceNodes = (html) => { this.resetStreamState('replaceNodes', { clearMsg: true, forceHeavy: true }); this.dom.clearNodes(); this.nodes.replaceNodes(html, this.scrollMgr); };
5415
+ // API: append/replace messages (non-streaming).
5416
+ api_appendNode = (payload) => { this.resetStreamState('appendNode'); this.data.append(payload); };
5417
+ api_replaceNodes = (payload) => { this.resetStreamState('replaceNodes', { clearMsg: true, forceHeavy: true }); this.dom.clearNodes(); this.data.replace(payload); };
5418
+
4501
5419
  // API: append to input area.
4502
- api_appendToInput = (html) => { this.nodes.appendToInput(html); this.scrollMgr.userInteracted = false; this.scrollMgr.scheduleScroll(); this.resetStreamState('appendToInput'); };
5420
+ api_appendToInput = (payload) => {
5421
+ this.nodes.appendToInput(payload);
5422
+
5423
+ // Ensure initial auto-follow is ON for the next stream that will start right after user input.
5424
+ // Rationale: previously, if the user had scrolled up, autoFollow could remain false and the
5425
+ // live stream would not follow even though we just sent a new input.
5426
+ this.scrollMgr.autoFollow = true; // explicitly re-enable page auto-follow
5427
+ this.scrollMgr.userInteracted = false; // Reset interaction so live scroll is allowed
5428
+
5429
+ // Keep lastScrollTop in sync to avoid misclassification in the next onscroll handler.
5430
+ try { this.scrollMgr.lastScrollTop = Utils.SE.scrollTop | 0; } catch (_) {}
5431
+
5432
+ // Non-live scroll to bottom right away, independent of autoFollow state.
5433
+ this.scrollMgr.scheduleScroll();
5434
+ // NOTE: No resetStreamState() here to avoid flicker/reflow issues while previewing user input.
5435
+ };
4503
5436
 
4504
5437
  // API: clear messages list.
4505
5438
  api_clearNodes = () => { this.dom.clearNodes(); this.resetStreamState('clearNodes', { clearMsg: true, forceHeavy: true }); };
@@ -4597,6 +5530,7 @@
4597
5530
 
4598
5531
  // API: restore collapsed state of codes in a given root.
4599
5532
  api_restoreCollapsedCode = (root) => this.renderer.restoreCollapsedCode(root);
5533
+
4600
5534
  // API: user-triggered page scroll.
4601
5535
  api_scrollToTopUser = () => this.scrollMgr.scrollToTopUser();
4602
5536
  api_scrollToBottomUser = () => this.scrollMgr.scrollToBottomUser();
@@ -4607,7 +5541,11 @@
4607
5541
 
4608
5542
  // API: custom markup rules control.
4609
5543
  api_getCustomMarkupRules = () => this.customMarkup.getRules();
4610
- api_setCustomMarkupRules = (rules) => { this.customMarkup.setRules(rules); };
5544
+ api_setCustomMarkupRules = (rules) => {
5545
+ this.customMarkup.setRules(rules);
5546
+ // Keep StreamEngine in sync with rules producing fenced code
5547
+ try { this.stream.setCustomFenceSpecs(this.customMarkup.getSourceFenceSpecs()); } catch (_) {}
5548
+ };
4611
5549
 
4612
5550
  // Initialize runtime (called on DOMContentLoaded).
4613
5551
  init() {
@@ -4615,15 +5553,13 @@
4615
5553
  this.dom.init();
4616
5554
  this.ui.ensureStickyHeaderStyle();
4617
5555
 
4618
- // Tips manager with rAF-based centering and rotation
4619
5556
  this.tips = new TipsManager(this.dom);
4620
-
4621
5557
  this.events.install();
4622
5558
 
4623
5559
  this.bridge.initQWebChannel(this.cfg.PID, (bridge) => {
4624
- const onChunk = (name, chunk) => this.api_appendStream(name, chunk);
4625
- const onNode = (html) => this.api_appendNode(html);
4626
- const onNodeReplace = (html) => this.api_replaceNodes(html);
5560
+ const onChunk = (name, chunk, type) => this.api_onChunk(name, chunk, type);
5561
+ const onNode = (payload) => this.api_appendNode(payload);
5562
+ const onNodeReplace = (payload) => this.api_replaceNodes(payload);
4627
5563
  const onNodeInput = (html) => this.api_appendToInput(html);
4628
5564
  this.bridge.connect(onChunk, onNode, onNodeReplace, onNodeInput);
4629
5565
  try { this.logger.bindBridge(this.bridge.bridge || this.bridge); } catch (_) {}
@@ -4647,7 +5583,6 @@
4647
5583
  }, this.stream.activeCode);
4648
5584
  this.highlighter.scheduleScanVisibleCodes(this.stream.activeCode);
4649
5585
 
4650
- // Start tips rotation; internal delay matches legacy timing (TIPS_INIT_DELAY_MS)
4651
5586
  this.tips.cycle();
4652
5587
  this.scrollMgr.updateScrollFab(true);
4653
5588
  }
@@ -4665,17 +5600,17 @@
4665
5600
  }
4666
5601
 
4667
5602
  // Ensure RafManager.cancel uses the correct group key cleanup.
4668
- if (typeof RafManager !== 'undefined' && RafManager.prototype && typeof RafManager.prototype.cancel === 'function') {
4669
- RafManager.prototype.cancel = function(key) {
4670
- const t = this.tasks.get(key);
4671
- if (!t) return;
4672
- this.tasks.delete(key);
4673
- if (t.group) {
4674
- const set = this.groups.get(t.group);
4675
- if (set) { set.delete(key); if (set.size === 0) this.groups.delete(t.group); }
5603
+ if (typeof RafManager !== 'undefined' && RafManager.prototype && typeof RafManager.prototype.cancel === 'function') {
5604
+ RafManager.prototype.cancel = function(key) {
5605
+ const t = this.tasks.get(key);
5606
+ if (!t) return;
5607
+ this.tasks.delete(key);
5608
+ if (t.group) {
5609
+ const set = this.groups.get(t.group);
5610
+ if (set) { set.delete(key); if (set.size === 0) this.groups.delete(t.group); }
5611
+ }
5612
+ };
4676
5613
  }
4677
- };
4678
- }
4679
5614
 
4680
5615
  const runtime = new Runtime();
4681
5616
 
@@ -4687,11 +5622,12 @@
4687
5622
  window.endStream = () => runtime.api_endStream();
4688
5623
  window.applyStream = (name, chunk) => runtime.api_applyStream(name, chunk);
4689
5624
  window.appendStream = (name, chunk) => runtime.api_appendStream(name, chunk);
5625
+ window.appendStreamTyped = (type, name, chunk) => runtime.api_onChunk(name, chunk, type);
4690
5626
  window.nextStream = () => runtime.api_nextStream();
4691
5627
  window.clearStream = () => runtime.api_clearStream();
4692
5628
 
4693
- window.appendNode = (html) => runtime.api_appendNode(html);
4694
- window.replaceNodes = (html) => runtime.api_replaceNodes(html);
5629
+ window.appendNode = (payload) => runtime.api_appendNode(payload);
5630
+ window.replaceNodes = (payload) => runtime.api_replaceNodes(payload);
4695
5631
  window.appendToInput = (html) => runtime.api_appendToInput(html);
4696
5632
 
4697
5633
  window.clearNodes = () => runtime.api_clearNodes();