// 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 '';
}
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 '';
}
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 '';
}
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