// markdown.js — markdown rendering + embed-URL converters extracted from helpers.js. // windyToEmbedUrl/youtubeToEmbedUrl/mapsToEmbedUrl + renderMarkdown (hand-rolled). // Generates inline onclick strings referencing functions in media.js/files.js/ // helpers-core.js (resolved at click time, so load order is not runtime-critical). function windyToEmbedUrl(url) { try { var u = new URL(url); // Already an embed URL — use as-is if (u.hostname === 'embed.windy.com') return url; // windy.com map URL → convert to embed if (/windy\.com/.test(u.hostname)) { var lat = u.searchParams.get('lat') || ''; var lon = u.searchParams.get('lon') || ''; var zoom = u.searchParams.get('zoom') || ''; var overlay = u.searchParams.get('overlay') || ''; var level = u.searchParams.get('level') || 'surface'; // Handle windy.com comma format: ?overlay,lat,lon,zoom or pathname /-overlay?overlay,lat,lon,zoom if (!lat || !lon) { var qs = u.search.slice(1); // e.g. 'radar,36.545,127.974,7' var parts = qs.split(','); if (parts.length >= 3) { overlay = overlay || parts[0]; lat = parts[1]; lon = parts[2]; zoom = zoom || parts[3] || '7'; } } lat = lat || '37.5'; lon = lon || '126.9'; zoom = zoom || '7'; overlay = overlay || 'rain'; // Map radar overlay name if (overlay === '-radar' || overlay === 'radar') overlay = 'radar'; return 'https://embed.windy.com/embed2.html?lat=' + lat + '&lon=' + lon + '&zoom=' + zoom + '&level=' + level + '&overlay=' + overlay + '&menu=&message=true&marker=&calendar=now&pressure=&type=map' + '&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C'; } return null; } catch(e) { return null; } } function youtubeToEmbedUrl(url) { try { var u = new URL(url); var id = null; if (u.hostname === 'youtu.be') { id = u.pathname.slice(1).split(/[/?]/)[0]; } else if (/youtube\.com/.test(u.hostname)) { id = u.searchParams.get('v'); if (!id) { var m = u.pathname.match(/\/(?:shorts|embed)\/([^/?]+)/); if (m) id = m[1]; } } if (!id) return null; var start = u.searchParams.get('t') || u.searchParams.get('start'); return 'https://www.youtube.com/embed/' + id + (start ? '?start=' + parseInt(start, 10) : ''); } catch(e) { return null; } } function mapsToEmbedUrl(url) { try { var u = new URL(url); var q = null; var placeMatch = u.pathname.match(/\/maps\/place\/([^/@?]+)/); if (placeMatch) q = decodeURIComponent(placeMatch[1].replace(/\+/g, ' ')); if (!q) { var searchMatch = u.pathname.match(/\/maps\/search\/([^/@?]+)/); if (searchMatch) q = decodeURIComponent(searchMatch[1].replace(/\+/g, ' ')); } if (!q) { var coordMatch = u.pathname.match(/\/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); if (coordMatch) q = coordMatch[1] + ',' + coordMatch[2]; } if (!q) q = u.searchParams.get('q') || u.searchParams.get('query'); if (!q) return null; return 'https://maps.google.com/maps?q=' + encodeURIComponent(q) + '&output=embed&hl=ko'; } catch(e) { return null; } } function renderMarkdown(text, opts) { if (!text) return ''; // Extract existing tags before escaping so they survive escHtml() var imgPlaceholders = []; var imgRe = /]*class=["']md-img["'][^>]*>/gi; var textSafe = text.replace(imgRe, function(m) { var idx = imgPlaceholders.length; imgPlaceholders.push(m); return '\x00IMG' + idx + '\x00'; }); var html = escHtml(textSafe); // Restore tags (already safe HTML) imgPlaceholders.forEach(function(img, i) { html = html.replace('\x00IMG' + i + '\x00', img); }); // Code blocks (``` ... ```) — escHtml() above already turned literal backticks into // ` (it escapes backtick/backslash/$ too, not just &<>"'), so matching on literal // backticks here never matched anything — every special widget block (midi-widget, abc, // lilypond, tradingview, kstock, smiles) silently fell through as escaped plain text. // Rendered widget HTML (smiles data-smiles attrs especially) can itself contain raw // "[X](Y)"-shaped substrings (SMILES stereo notation like [C@@H](C) reads exactly like // markdown link syntax) that the later link/image/bold regexes would then mangle mid- // attribute. Stash each block's final HTML behind a placeholder and restore it after // all other text transforms have run, the same trick used for above. var codeBlockPlaceholders = []; html = html.replace(/```([\w-]*)\n([\s\S]*?)```/g, function(_, lang, code) { var result = (function() { // code.trim() would strip leading whitespace off only the FIRST line (it operates on // the whole string, not per-line), misaligning ASCII-art whose top row is indented // (e.g. a right-shifted "O" over a left-aligned structure below it). Only drop fully // blank leading/trailing lines so every line's own indentation survives intact. var trimmed = code.replace(/^\n+/, '').replace(/\n+$/, ''); if (lang === 'midi-widget') { try { var mwRaw = trimmed.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/gi,'"').replace(/'/g,"'"); var mwData = JSON.parse(mwRaw); var mwFilename = mwData.filename || (mwData.saved_to ? mwData.saved_to.split('/').pop() : 'score.mid'); return buildMidiPickerWidget(mwData, mwFilename).html; } catch(e) { return '
' + escHtml(trimmed) + '
'; } } if (lang === 'abc' || lang === 'lilypond') { var encoded = encodeURIComponent(trimmed); var widgetId = 'music-' + Math.random().toString(36).slice(2, 9); return '
' + '
' + '🎵 ' + (lang === 'abc' ? 'ABC Notation' : 'LilyPond') + '' + '
' + '' + '' + '' + '
' + '
' + '
' + '
' + escHtml(trimmed) + '
' + '' + '
' + '
'; } if (lang === 'tradingview') { var tvSymbol = '', tvInterval = 'D'; // code content is already HTML-escaped at this point — unescape before parsing var tvRaw = trimmed.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/gi,'"').replace(/'/g,"'").trim(); if (tvRaw.startsWith('{')) { try { var tvJson = JSON.parse(tvRaw); tvSymbol = (tvJson.symbol || tvJson.SYMBOL || '').toString().toUpperCase(); tvInterval = (tvJson.interval || tvJson.INTERVAL || 'D').toString().toUpperCase(); } catch(e) { tvSymbol = tvRaw.split('|')[0].trim().toUpperCase(); } } else { var parts = tvRaw.split('|'); tvSymbol = (parts[0] || '').trim().toUpperCase(); tvInterval = (parts[1] || 'D').trim().toUpperCase(); } if (!tvSymbol) return '
' + trimmed + '
'; var tvId = 'tv-' + Math.random().toString(36).slice(2, 9); return '
' + '
' + '📈 ' + escHtml(tvSymbol) + ' · ' + escHtml(tvInterval) + '' + '
' + '' + '' + '' + '
' + '
' + '
' + '
' + '
' + '
'; } if (lang === 'kstock') { var ksRaw = trimmed.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').trim(); var ksParts = ksRaw.split('|'); var ksCode = ksParts[0].trim().replace(/\D/g,''); // 숫자만 (005930) var ksPeriod = (ksParts[1] || 'day').trim().toLowerCase(); var ksPeriodMap = { '1d':'day','day':'day','1w':'week','week':'week','1m':'month','month':'month','3m':'3month','3month':'3month','1y':'year','year':'year','3y':'3year','3year':'3year' }; var ksNaverPeriod = ksPeriodMap[ksPeriod] || 'day'; var ksPeriodLabel = { day:'일봉', week:'주봉', month:'월봉', '3month':'3개월', year:'1년', '3year':'3년' }[ksNaverPeriod] || ksNaverPeriod; if (!ksCode) return '
' + trimmed + '
'; var ksId = 'ks-' + Math.random().toString(36).slice(2, 9); var ksImgUrl = 'https://ssl.pstatic.net/imgfinance/chart/item/area/' + ksNaverPeriod + '/' + ksCode + '.png'; var ksNaverUrl = 'https://finance.naver.com/item/main.naver?code=' + ksCode; return '
' + '
' + '📈 ' + escHtml(ksCode) + ' · ' + escHtml(ksPeriodLabel) + ' · 네이버금융' + '
' + '네이버금융 ↗' + '' + '' + '
' + '
' + '
' + '' + escHtml(ksCode) + ' 차트차트를 불러올 수 없습니다.\'">' + '
' + '
'; } if (lang === 'smiles') { // Also undo escHtml()'s backslash/backtick/$ escaping (not just the amp/lt/gt/quote // set the other widget blocks unescape) — SMILES cis/trans bond notation uses // backslash (e.g. C/C=C/C), which would otherwise stay mangled as \. var smRaw = trimmed.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/gi,'"').replace(/'/g,"'").replace(/`/g,'`').replace(/$/g,'$').replace(/\/g,'\\').trim(); if (!smRaw) return '
' + trimmed + '
'; // Textbook-style: no header/button chrome, auto-rendered by renderSmilesIn() right // after insertion (mirrors renderKatexIn — innerHTML doesn't run embedded