Add extended markdown: task lists, highlight, sub/superscript, heading IDs, emoji

- Task lists: - [x] / - [ ] with checkbox glyphs, done items struck through
- Highlight: ==text== -> <mark>
- Subscript: ~text~ -> <sub> (runs after ~~ strikethrough to avoid conflict)
- Superscript: ^text^ -> <sup>
- Heading IDs: ### Title {#my-id} adds id attribute for anchor links
- Ordered lists: now properly wrapped in <ol>
- Emoji: :name: shortcodes (~100 common emojis)
- CSS for all new elements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 00:13:38 -04:00
parent 47c631ad4f
commit cd83464c5d
2 changed files with 87 additions and 11 deletions
+10
View File
@@ -5163,6 +5163,16 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
.lt-markdown a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
.lt-markdown strong { color: var(--text-primary); }
.lt-markdown img, .md-image { max-width: 100%; height: auto; border: 1px solid var(--border-dim); border-radius: 2px; display: block; margin: 0.5rem 0; }
.lt-markdown mark { background: var(--accent-yellow-dim, #2a2500); color: var(--accent-yellow, #e6c619); padding: 0 3px; border-radius: 2px; }
.lt-markdown del { color: var(--text-muted); text-decoration: line-through; }
.lt-markdown sub, .lt-markdown sup { font-size: 0.7em; line-height: 0; }
.lt-markdown .task-item { list-style: none; margin-left: -1.2em; }
.lt-markdown .task-cb { margin-right: 0.35em; font-size: 1em; }
.lt-markdown .task-done { color: var(--text-muted); text-decoration: line-through; }
.lt-markdown .task-todo { color: var(--text-secondary); }
.lt-markdown ol { padding-left: 1.5em; margin: 0.5rem 0; }
.lt-markdown ol li { color: var(--text-secondary); }
.lt-markdown ol li::marker { color: var(--accent-orange); }
.lt-markdown table { width: 100%; border-collapse: collapse; font-size: 0.78rem; margin: 0.75rem 0; }
.lt-markdown th { background: var(--bg-secondary); color: var(--accent-cyan); padding: 0.4rem 0.6rem; border: 1px solid var(--border-dim); text-align: left; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
.lt-markdown td { padding: 0.35rem 0.6rem; border: 1px solid var(--border-dim); color: var(--text-secondary); }
+77 -11
View File
@@ -33,6 +33,9 @@ function parseMarkdown(markdown) {
// Tables (must be processed before other block elements)
html = parseMarkdownTables(html);
// Emoji :name: — common set
html = replaceEmoji(html);
// Bold (**text** or __text__)
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
@@ -41,9 +44,18 @@ function parseMarkdown(markdown) {
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
// Strikethrough (~~text~~)
// Strikethrough (~~text~~) — must run before subscript (~)
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Highlight (==text==)
html = html.replace(/==(.+?)==/g, '<mark>$1</mark>');
// Subscript H~2~O — single tilde (not preceded/followed by another tilde)
html = html.replace(/(?<!~)~([^~\n]+?)~(?!~)/g, '<sub>$1</sub>');
// Superscript X^2^ — caret pair
html = html.replace(/\^([^\^\n]+?)\^/g, '<sup>$1</sup>');
// Images ![alt](url) - must come before link handler
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, url) {
if (/^https?:/i.test(url)) {
@@ -62,21 +74,29 @@ function parseMarkdown(markdown) {
return text;
});
// Auto-link bare URLs (http, https, ftp)
// Auto-link bare URLs (http, https)
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
// Headers (# H1, ## H2, etc.)
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Headings with optional {#id} anchor — ### My Heading {#my-id}
html = html.replace(/^(#{1,6})\s+(.+?)\s*(?:\{#([a-z0-9_-]+)\})?$/gm, function(match, hashes, text, id) {
const level = hashes.length;
const idAttr = id ? ' id="' + id + '"' : '';
return '<h' + level + idAttr + '>' + text + '</h' + level + '>';
});
// Lists
// Unordered lists (- item or * item)
// Task lists — must run before general list processing
html = html.replace(/^\s*-\s+\[x\]\s+(.+)$/gim, '<li class="task-item task-done"><span class="task-cb">&#x2611;</span> $1</li>');
html = html.replace(/^\s*-\s+\[ \]\s+(.+)$/gm, '<li class="task-item task-todo"><span class="task-cb">&#x2610;</span> $1</li>');
// Unordered lists (- item or * item) — wrap consecutive <li> in <ul>
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
html = html.replace(/(<li>(?:(?!<li>|<\/ul>)[\s\S])*<\/li>(?:\n<li>(?:(?!<li>|<\/ul>)[\s\S])*<\/li>)*)/g, '<ul>$1</ul>');
// Ordered lists (1. item)
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
// Ordered lists (1. item) — wrap in <ol>
html = html.replace(/(?:^\s*\d+\.\s+.+$\n?)+/gm, function(block) {
const items = block.trim().replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
return '<ol>' + items + '</ol>';
});
// Blockquotes (> text)
html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote>$1</blockquote>');
@@ -104,6 +124,52 @@ function parseMarkdown(markdown) {
return html;
}
/**
* Replace :emoji: shortcodes with Unicode characters
*/
const _emojiMap = {
// Faces & emotions
smile: '😊', grin: '😁', joy: '😂', rofl: '🤣', smiley: '😃', sweat_smile: '😅',
wink: '😉', blush: '😊', heart_eyes: '😍', kissing: '😗', thinking: '🤔',
raised_eyebrow: '🤨', neutral_face: '😐', expressionless: '😑', unamused: '😒',
roll_eyes: '🙄', pensive: '😔', confused: '😕', worried: '😟', cry: '😢',
sob: '😭', scream: '😱', angry: '😠', rage: '😡', skull: '💀', sunglasses: '😎',
nerd: '🤓', monocle: '🧐', clown: '🤡', ghost: '👻', robot: '🤖', alien: '👽',
// Hands & people
thumbsup: '👍', '+1': '👍', thumbsdown: '👎', '-1': '👎', clap: '👏',
wave: '👋', raised_hands: '🙌', pray: '🙏', point_up: '☝️', point_right: '👉',
point_left: '👈', point_down: '👇', fist: '✊', punch: '👊', v: '✌️', ok_hand: '👌',
muscle: '💪', eyes: '👀', eye: '👁️', ear: '👂', brain: '🧠', man: '👨', woman: '👩',
// Hearts & symbols
heart: '❤️', orange_heart: '🧡', yellow_heart: '💛', green_heart: '💚',
blue_heart: '💙', purple_heart: '💜', black_heart: '🖤', broken_heart: '💔',
star: '⭐', star2: '🌟', sparkles: '✨', fire: '🔥', boom: '💥', zap: '⚡',
check: '✅', white_check_mark: '✅', x: '❌', heavy_check_mark: '✔️',
warning: '⚠️', no_entry: '⛔', stop_sign: '🛑', prohibited: '🚫',
question: '❓', exclamation: '❗', grey_question: '❔', grey_exclamation: '❕',
100: '💯', tada: '🎉', confetti_ball: '🎊', trophy: '🏆', medal: '🥇',
// Tech & work
bug: '🐛', rocket: '🚀', computer: '💻', keyboard: '⌨️', mouse: '🖱️',
printer: '🖨️', phone: '📱', email: '📧', inbox_tray: '📥', outbox_tray: '📤',
memo: '📝', pencil: '✏️', pen: '🖊️', paperclip: '📎', link: '🔗',
hammer: '🔨', wrench: '🔧', gear: '⚙️', lock: '🔒', unlock: '🔓',
key: '🔑', mag: '🔍', bar_chart: '📊', chart_increasing: '📈', chart_decreasing: '📉',
clipboard: '📋', calendar: '📅', clock: '🕐', hourglass: '⏳', bell: '🔔',
mute: '🔇', loud_sound: '🔊', bulb: '💡', battery: '🔋', electric_plug: '🔌',
recycle: '♻️', package: '📦', label: '🏷️', bookmark: '🔖', flag: '🚩',
// Nature & misc
sun: '☀️', moon: '🌙', cloud: '☁️', snowflake: '❄️', umbrella: '☂️',
dog: '🐶', cat: '🐱', pizza: '🍕', coffee: '☕', beer: '🍺',
white_flag: '🏳️', checkered_flag: '🏁', construction: '🚧', sos: '🆘',
info: '️', new: '🆕', free: '🆓', cool: '🆒', up: '🆙', soon: '🔜',
};
function replaceEmoji(text) {
return text.replace(/:([a-z0-9_+\-]+):/g, function(match, name) {
return _emojiMap[name] || match;
});
}
/**
* Parse markdown tables
* Supports: | Header | Header |