feat(reader): full Markdown and HTML rendering for GitHub READMEs, tables, images and links

This commit is contained in:
2026-10-05 13:30:00 +03:00
parent c5e0e2dcfa
commit ca70580e62
5 changed files with 432 additions and 54 deletions
+134
View File
@@ -2808,6 +2808,140 @@ body {
margin: 32px 0;
}
/* Headings */
.reader-content-body h1,
.reader-content-body h2,
.reader-content-body h3,
.reader-content-body h4,
.reader-content-body h5,
.reader-content-body h6 {
color: var(--text-main);
font-weight: 700;
line-height: 1.35;
margin: 32px 0 16px;
text-indent: 0 !important;
}
.reader-content-body h1 {
font-size: 28px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
}
.reader-content-body h2 {
font-size: 23px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
}
.reader-content-body h3 {
font-size: 19px;
}
.reader-content-body h4 {
font-size: 17px;
}
/* Markdown Tables */
.reader-table-wrap {
overflow-x: auto;
margin: 24px 0;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-surface);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.reader-table {
width: 100%;
border-collapse: collapse;
font-size: 14.5px;
line-height: 1.6;
text-indent: 0 !important;
}
.reader-table th,
.reader-table td {
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
border-right: 1px solid var(--border-color);
vertical-align: top;
text-indent: 0 !important;
}
.reader-table th:last-child,
.reader-table td:last-child {
border-right: none;
}
.reader-table tr:last-child td {
border-bottom: none;
}
.reader-table th {
background-color: rgba(255, 255, 255, 0.05);
font-weight: 700;
color: var(--text-main);
text-align: left;
}
.reader-table tbody tr:hover {
background-color: rgba(255, 255, 255, 0.02);
}
/* Links & Images */
.reader-link {
color: var(--primary);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
transition: color 0.15s ease;
}
.reader-link:hover {
color: var(--primary-hover);
}
.reader-img {
max-width: 100%;
height: auto;
border-radius: var(--radius-md);
margin: 8px 0;
vertical-align: middle;
}
.reader-content-body p img {
margin: 4px 6px;
display: inline-block;
vertical-align: middle;
}
.reader-content-body p[style*="text-align: center"],
.reader-content-body p.text-center {
text-indent: 0 !important;
}
.reader-content-body strong,
.reader-content-body b {
font-weight: 700;
color: var(--text-main);
}
.reader-content-body em,
.reader-content-body i {
font-style: italic;
}
.reader-content-body sub,
.reader-content-body sup {
font-size: 0.8em;
line-height: 0;
position: relative;
vertical-align: baseline;
}
.reader-content-body sub { bottom: -0.25em; }
.reader-content-body sup { top: -0.5em; }
/* Interactive Words */
.reader-word {
cursor: pointer;
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Notes — Персональный тренажер и словарь английского языка</title>
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
<link rel="stylesheet" href="/static/css/style.css?v=2.5">
<link rel="stylesheet" href="/static/css/style.css?v=2.6">
<!-- PWA meta and icons -->
<link rel="manifest" href="/manifest.json">
@@ -922,6 +922,6 @@
<!-- Audio Element for native streaming -->
<audio id="global-audio-player" preload="none"></audio>
<script src="/static/js/app.js?v=2.5"></script>
<script src="/static/js/app.js?v=2.6"></script>
</body>
</html>
+276 -47
View File
@@ -1888,16 +1888,54 @@ document.addEventListener('DOMContentLoaded', () => {
codeBlock.appendChild(header);
codeBlock.appendChild(pre);
readerContentBody.appendChild(codeBlock);
} else if (block.type === 'table') {
const wrap = document.createElement('div');
wrap.className = 'reader-table-wrap';
const table = document.createElement('table');
table.className = 'reader-table';
if (block.headers && block.headers.length > 0) {
const thead = document.createElement('thead');
const headerTr = document.createElement('tr');
block.headers.forEach((hText, colIdx) => {
const th = document.createElement('th');
if (block.alignments && block.alignments[colIdx]) {
th.style.textAlign = block.alignments[colIdx];
}
renderInteractiveSpans(th, hText, hText, knownWordsMap);
headerTr.appendChild(th);
});
thead.appendChild(headerTr);
table.appendChild(thead);
}
if (block.rows && block.rows.length > 0) {
const tbody = document.createElement('tbody');
block.rows.forEach(row => {
const tr = document.createElement('tr');
row.forEach((cellText, colIdx) => {
const td = document.createElement('td');
if (block.alignments && block.alignments[colIdx]) {
td.style.textAlign = block.alignments[colIdx];
}
renderInteractiveSpans(td, cellText, cellText, knownWordsMap);
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
}
wrap.appendChild(table);
readerContentBody.appendChild(wrap);
} else if (block.type === 'heading') {
const tag = `h${Math.min(4, Math.max(2, block.level))}`;
const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`;
const h = document.createElement(tag);
if (block.align) h.style.textAlign = block.align;
renderInteractiveSpans(h, block.text, block.text, knownWordsMap);
readerContentBody.appendChild(h);
} else if (block.type === 'blockquote') {
const bq = document.createElement('blockquote');
const p = document.createElement('p');
renderInteractiveSpans(p, block.text, block.text, knownWordsMap);
bq.appendChild(p);
renderInteractiveSpans(bq, block.text, block.text, knownWordsMap);
readerContentBody.appendChild(bq);
} else if (block.type === 'ul' || block.type === 'ol') {
const listEl = document.createElement(block.type);
@@ -1912,6 +1950,10 @@ document.addEventListener('DOMContentLoaded', () => {
} else {
// paragraph
const p = document.createElement('p');
if (block.align) {
p.style.textAlign = block.align;
p.classList.add('text-center');
}
renderInteractiveSpans(p, block.text, block.text, knownWordsMap);
readerContentBody.appendChild(p);
}
@@ -1964,7 +2006,41 @@ document.addEventListener('DOMContentLoaded', () => {
continue;
}
// 3. Headings: #, ##, ###, ####, #####, ######
// 3. Markdown Table: lines starting with | and ending with |
if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
const tableLines = [];
while (i < lines.length && lines[i].trim().startsWith('|')) {
tableLines.push(lines[i].trim());
i++;
}
if (tableLines.length >= 2) {
const splitRow = (rowStr) => {
const inner = rowStr.replace(/^\|/, '').replace(/\|$/, '');
return inner.split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'));
};
const headers = splitRow(tableLines[0]);
const alignRow = splitRow(tableLines[1]);
const alignments = alignRow.map(col => {
const tc = col.trim();
if (tc.startsWith(':') && tc.endsWith(':')) return 'center';
if (tc.endsWith(':')) return 'right';
return 'left';
});
const rows = [];
for (let r = 2; r < tableLines.length; r++) {
rows.push(splitRow(tableLines[r]));
}
blocks.push({
type: 'table',
headers: headers,
alignments: alignments,
rows: rows
});
continue;
}
}
// 4. Markdown Headings: #, ##, ###, ####, #####, ######
const headingMatch = line.match(/^(\#{1,6})\s+(.+)$/);
if (headingMatch) {
blocks.push({
@@ -1976,14 +2052,31 @@ document.addEventListener('DOMContentLoaded', () => {
continue;
}
// 4. Horizontal Rule: --- or *** or ___
if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed)) {
// HTML Heading: <h1...>...</h1>
const htmlHeadingMatch = line.match(/^<h([1-6])([^>]*)>(.*?)<\/h\1>/i);
if (htmlHeadingMatch) {
const level = parseInt(htmlHeadingMatch[1], 10);
const attrs = htmlHeadingMatch[2] || '';
const hText = htmlHeadingMatch[3].trim();
const align = /align=["']?center["']?/i.test(attrs) ? 'center' : '';
blocks.push({
type: 'heading',
level: level,
text: hText,
align: align
});
i++;
continue;
}
// 5. Horizontal Rule: --- or *** or ___ or <hr>
if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed) || trimmed === '<hr>' || trimmed === '<hr/>' || trimmed === '<hr />') {
blocks.push({ type: 'hr' });
i++;
continue;
}
// 5. Blockquote: > ...
// 6. Blockquote: > ...
if (trimmed.startsWith('>')) {
const quoteLines = [];
while (i < lines.length && lines[i].trim().startsWith('>')) {
@@ -1997,7 +2090,7 @@ document.addEventListener('DOMContentLoaded', () => {
continue;
}
// 6. Unordered List: - item or * item or + item
// 7. Unordered List: - item or * item or + item
if (/^[-*+]\s+/.test(trimmed)) {
const listItems = [];
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
@@ -2011,7 +2104,7 @@ document.addEventListener('DOMContentLoaded', () => {
continue;
}
// 7. Ordered List: 1. item
// 8. Ordered List: 1. item
if (/^\d+\.\s+/.test(trimmed)) {
const listItems = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
@@ -2025,7 +2118,27 @@ document.addEventListener('DOMContentLoaded', () => {
continue;
}
// 8. Regular paragraph: gather lines until next block marker or empty line
// 9. Centered HTML block (<p align="center"> or <div align="center">)
if (/^<(?:p|div)[^>]*align=["']?center["']?[^>]*>/i.test(trimmed)) {
const htmlLines = [];
while (i < lines.length) {
const cur = lines[i];
htmlLines.push(cur.trim());
if (/<\/(?:p|div)>/i.test(cur)) {
i++;
break;
}
i++;
}
blocks.push({
type: 'paragraph',
text: htmlLines.join(' '),
align: 'center'
});
continue;
}
// 10. Regular paragraph: gather lines until next block marker or empty line
const paraLines = [];
while (i < lines.length) {
const cur = lines[i];
@@ -2033,10 +2146,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (!curTrim) break;
if (curTrim.startsWith('```') ||
/^#{1,6}\s+/.test(curTrim) ||
/^<h[1-6][^>]*>/i.test(curTrim) ||
(curTrim.startsWith('|') && curTrim.endsWith('|')) ||
curTrim.startsWith('>') ||
/^[-*+]\s+/.test(curTrim) ||
/^\d+\.\s+/.test(curTrim) ||
/^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim)) {
/^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim) ||
/^<(?:p|div)[^>]*align=["']?center/i.test(curTrim)) {
break;
}
paraLines.push(curTrim);
@@ -2053,47 +2169,160 @@ document.addEventListener('DOMContentLoaded', () => {
return blocks;
}
// Render text with inline formatting and interactive clickable word spans
// Render text with full inline Markdown/HTML formatting and interactive clickable word spans
function renderInteractiveSpans(container, text, contextText, knownWordsMap) {
if (!text) return;
const inlineCodeParts = text.split(/(`[^`]+`)/);
inlineCodeParts.forEach(part => {
if (part.startsWith('`') && part.endsWith('`') && part.length >= 2) {
const codeNode = document.createElement('code');
codeNode.className = 'reader-inline-code';
codeNode.textContent = part.slice(1, -1);
container.appendChild(codeNode);
return;
// 1. Normalize entities & clean non-semantic wrapper tags
let processed = text
.replace(/&middot;/g, '·')
.replace(/&bull;/g, '•')
.replace(/&mdash;/g, '—')
.replace(/&ndash;/g, '–');
// Strip enclosing <p...> and </p> tags if present
processed = processed.replace(/^<p[^>]*>/i, '').replace(/<\/p>$/i, '');
// Strip wrapper picture and source tags
processed = processed.replace(/<\/?(?:picture|source)[^>]*>/gi, '');
// 2. Convert Markdown inline patterns into HTML
// Images: ![alt](url) -> <img src="url" alt="alt">
processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
// Links: [text](url) -> <a href="url">$1</a>
processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
// Inline Code: `code` -> <code class="reader-inline-code">$1</code>
processed = processed.replace(/`([^`]+)`/g, '<code class="reader-inline-code">$1</code>');
// Bold: **text** or __text__ -> <strong>text</strong>
processed = processed.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
processed = processed.replace(/__([^_]+)__/g, '<strong>$1</strong>');
// Italic: *text* or _text_ -> <em>text</em>
processed = processed.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
processed = processed.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>');
// 3. Parse into DOM tree
const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${processed}</body>`, 'text/html');
// 4. Recursive transformer: keeps formatting elements, makes text nodes interactive words
function transformDOMNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
const raw = node.textContent;
if (!raw) return document.createTextNode('');
const frag = document.createDocumentFragment();
const tokens = raw.split(/([A-Za-z0-9'-]+)/);
for (const token of tokens) {
const isWord = /^[A-Za-z0-9'-]+$/.test(token) && token.replace(/[^A-Za-z0-9]/g, '').length >= 1;
if (isWord) {
const span = document.createElement('span');
span.className = 'reader-word';
span.dataset.word = token;
span.textContent = token;
const lower = token.toLowerCase();
if (knownWordsMap && knownWordsMap.has(lower)) {
const card = knownWordsMap.get(lower);
span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning');
}
if (isHighlightEnabled) {
span.classList.add('highlight-enabled');
}
span.addEventListener('click', (e) => {
e.stopPropagation();
handleWordClick(token, contextText || raw, span);
});
frag.appendChild(span);
} else {
frag.appendChild(document.createTextNode(token));
}
}
return frag;
}
const tokens = part.split(/([A-Za-z0-9'-]+)/);
tokens.forEach(token => {
const isWord = /^[A-Za-z0-9'-]+$/.test(token) && token.replace(/[^A-Za-z0-9]/g, '').length >= 1;
if (isWord) {
const span = document.createElement('span');
span.className = 'reader-word';
span.dataset.word = token;
span.textContent = token;
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
const lower = token.toLowerCase();
if (knownWordsMap && knownWordsMap.has(lower)) {
const card = knownWordsMap.get(lower);
span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning');
}
if (isHighlightEnabled) {
span.classList.add('highlight-enabled');
}
span.addEventListener('click', (e) => {
e.stopPropagation();
handleWordClick(token, contextText || text, span);
});
container.appendChild(span);
} else {
container.appendChild(document.createTextNode(token));
// Code and pre: keep as code badge, DO NOT tokenize words!
if (tag === 'code' || tag === 'pre') {
const codeEl = document.createElement('code');
codeEl.className = 'reader-inline-code';
codeEl.textContent = node.textContent;
return codeEl;
}
});
// Images
if (tag === 'img') {
const img = document.createElement('img');
img.className = 'reader-img';
img.src = node.getAttribute('src') || '';
img.alt = node.getAttribute('alt') || '';
if (node.getAttribute('width')) img.setAttribute('width', node.getAttribute('width'));
if (node.getAttribute('height')) img.setAttribute('height', node.getAttribute('height'));
// Hide gracefully if broken/local relative asset
img.addEventListener('error', () => {
img.style.display = 'none';
});
return img;
}
// Links
if (tag === 'a') {
const a = document.createElement('a');
a.className = 'reader-link';
a.href = node.getAttribute('href') || '#';
a.target = '_blank';
a.rel = 'noopener noreferrer';
Array.from(node.childNodes).forEach(child => {
a.appendChild(transformDOMNode(child));
});
return a;
}
if (tag === 'br') {
return document.createElement('br');
}
if (tag === 'strong' || tag === 'b') {
const strong = document.createElement('strong');
Array.from(node.childNodes).forEach(child => {
strong.appendChild(transformDOMNode(child));
});
return strong;
}
if (tag === 'em' || tag === 'i') {
const em = document.createElement('em');
Array.from(node.childNodes).forEach(child => {
em.appendChild(transformDOMNode(child));
});
return em;
}
if (tag === 'sub' || tag === 'sup') {
const el = document.createElement(tag);
Array.from(node.childNodes).forEach(child => {
el.appendChild(transformDOMNode(child));
});
return el;
}
// Generic containers (span, div, etc.)
const containerEl = document.createElement(tag === 'p' ? 'span' : tag);
if (node.hasAttribute('style')) {
containerEl.setAttribute('style', node.getAttribute('style'));
}
Array.from(node.childNodes).forEach(child => {
containerEl.appendChild(transformDOMNode(child));
});
return containerEl;
}
return document.createTextNode('');
}
Array.from(doc.body.childNodes).forEach(childNode => {
container.appendChild(transformDOMNode(childNode));
});
}