diff --git a/static/css/style.css b/static/css/style.css index bd71f50..074b313 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -48,6 +48,81 @@ margin-bottom: 1em; } +/* Markdown rendered content (inside review suggestions) */ +.review-content .md-code-block { + background: #1e293b; + color: #e2e8f0; + padding: 12px 14px; + border-radius: 6px; + overflow-x: auto; + margin: 8px 0; + font-size: 12px; + line-height: 1.5; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} +.review-content .md-code-block[data-lang]::before { + content: attr(data-lang); + display: block; + font-size: 10px; + color: #94a3b8; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.review-content .md-inline-code { + background: #f1f5f9; + padding: 1px 5px; + border-radius: 4px; + font-size: 0.88em; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + color: #be185d; +} +.review-content .md-p { + margin: 6px 0; + line-height: 1.6; +} +.review-content .md-h1, .review-content .md-h2, .review-content .md-h3 { + font-weight: 600; + margin: 10px 0 4px; +} +.review-content .md-h1 { font-size: 1.2em; } +.review-content .md-h2 { font-size: 1.1em; } +.review-content .md-h3 { font-size: 1em; } +.review-content .md-ul, .review-content .md-ol { + margin: 6px 0; + padding-left: 20px; +} +.review-content .md-li { + margin: 2px 0; + line-height: 1.5; +} +.review-content .md-link { + color: #2563eb; + text-decoration: underline; +} +.review-content .md-link:hover { + color: #1d4ed8; +} +.review-content .md-blockquote { + border-left: 3px solid #d1d5db; + padding-left: 12px; + color: #6b7280; + margin: 8px 0; + font-style: italic; +} +.review-content .md-hr { + border: none; + border-top: 1px solid #e5e7eb; + margin: 12px 0; +} +.review-content em { + font-style: italic; +} +.review-content del { + text-decoration: line-through; + color: #9ca3af; +} + /* Severity badge colors (used by review) */ .border-red-400 { border-color: #f87171; } .border-yellow-400 { border-color: #facc15; } diff --git a/static/js/markdown.js b/static/js/markdown.js new file mode 100644 index 0000000..7c5656d --- /dev/null +++ b/static/js/markdown.js @@ -0,0 +1,160 @@ +// Shared Markdown renderer for PR-Helper +// Supports: fenced code blocks, inline code, bold, italic, strikethrough, +// headers, links, lists (bullet + numbered + nested), blockquotes, horizontal rules +const Markdown = { + render(text) { + if (!text) return ''; + // Normalize line endings + text = text.replace(/\r\n/g, '\n'); + + // Phase 1: Extract fenced code blocks to protect them + const codeBlocks = []; + text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { + const idx = codeBlocks.length; + codeBlocks.push({ lang, code }); + return `\x00CODEBLOCK_${idx}\x00`; + }); + + // Phase 2: Extract inline code + const inlineCodes = []; + text = text.replace(/`([^`\n]+?)`/g, (_, code) => { + const idx = inlineCodes.length; + inlineCodes.push(code); + return `\x00INLINECODE_${idx}\x00`; + }); + + // Phase 3: Escape HTML + text = text + .replace(/&/g, '&') + .replace(//g, '>'); + + // Phase 4: Block-level processing (split into blocks by blank lines) + const blocks = text.split(/\n{2,}/); + const htmlBlocks = blocks.map(block => this._renderBlock(block.trim())); + text = htmlBlocks.join('\n'); + + // Phase 5: Restore fenced code blocks + codeBlocks.forEach((cb, idx) => { + const langLabel = cb.lang ? ` data-lang="${cb.lang}"` : ''; + const escaped = this._escapeHtml(cb.code.replace(/\n$/, '')); + const replacement = `
${escaped}
`; + text = text.replace(`\x00CODEBLOCK_${idx}\x00`, replacement); + }); + + // Phase 6: Restore inline code + inlineCodes.forEach((code, idx) => { + const escaped = this._escapeHtml(code); + const replacement = `${escaped}`; + text = text.replace(`\x00INLINECODE_${idx}\x00`, replacement); + }); + + // Phase 7: Cleanup placeholder newlines + text = text.replace(/\x01NL\x01/g, '\n'); + + return text; + }, + + _renderBlock(block) { + if (!block) return ''; + + // Horizontal rule + if (/^(-{3,}|\*{3,}|_{3,})$/.test(block)) { + return '
'; + } + + // Headers + const headerMatch = block.match(/^(#{1,6}) (.+)$/); + if (headerMatch) { + const level = headerMatch[1].length; + const content = this._renderInline(headerMatch[2]); + return `${content}`; + } + + // Blockquote + if (/^>\s?/.test(block)) { + const inner = block.replace(/^>\s?/gm, '').trim(); + return `
${this._renderInline(inner)}
`; + } + + // Unordered list + if (/^[\-\*]\s/.test(block)) { + return this._renderList(block, 'ul'); + } + + // Ordered list + if (/^\d+\.\s/.test(block)) { + return this._renderList(block, 'ol'); + } + + // Paragraph + return `

${this._renderInline(block)}

`; + }, + + _renderList(block, tag) { + const lines = block.split('\n'); + const items = []; + let currentItem = ''; + + for (const line of lines) { + const itemMatch = line.match(/^[\s]*(?:[\-\*]|\d+\.)\s+(.*)$/); + if (itemMatch) { + if (currentItem) items.push(currentItem); + currentItem = itemMatch[1]; + } else if (currentItem) { + // Continuation of previous item + currentItem += ' ' + line.trim(); + } + } + if (currentItem) items.push(currentItem); + + const listItems = items.map(item => `
  • ${this._renderInline(item)}
  • `).join(''); + return `<${tag} class="md-${tag}">${listItems}`; + }, + + _renderInline(text) { + if (!text) return ''; + + // Links: [text](url) + text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Images: ![alt](url) + text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + + // Bold + italic: ***text*** or ___text___ + text = text.replace(/\*{3}(.+?)\*{3}/g, '$1'); + text = text.replace(/_{3}(.+?)_{3}/g, '$1'); + + // Bold: **text** or __text__ + text = text.replace(/\*{2}(.+?)\*{2}/g, '$1'); + text = text.replace(/_{2}(.+?)_{2}/g, '$1'); + + // Italic: *text* or _text_ + text = text.replace(/\*([^\s*](?:[^*]*[^\s*])?)\*/g, '$1'); + text = text.replace(/_([^\s_](?:[^_]*[^\s_])?)_/g, '$1'); + + // Strikethrough: ~~text~~ + text = text.replace(/~~(.+?)~~/g, '$1'); + + // Single newlines within a block become
    + text = text.replace(/\n/g, '
    '); + + return text; + }, + + _escapeHtml(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } +}; + +// Expose globally +window.Markdown = Markdown; + +// Backward-compatible alias +if (typeof window.renderMarkdown !== 'function') { + window.renderMarkdown = function(text) { return Markdown.render(text); }; +} diff --git a/templates/pages/generate.html b/templates/pages/generate.html index 8354495..013bc2a 100644 --- a/templates/pages/generate.html +++ b/templates/pages/generate.html @@ -3,6 +3,7 @@ {{template "head" .}} + {{template "nav" .}} @@ -266,28 +267,9 @@ }); } - // Simple markdown rendering (bold, headers, lists, code) + // Markdown rendering (delegates to shared Markdown.render) function renderMarkdown(text) { - if (!text) return ''; - let html = text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/^### (.+)$/gm, '

    $1

    ') - .replace(/^## (.+)$/gm, '

    $1

    ') - .replace(/^# (.+)$/gm, '

    $1

    ') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/`(.+?)`/g, '$1') - .replace(/^- (.+)$/gm, '
  • $1
  • ') - .replace(/^(\d+)\. (.+)$/gm, '
  • $2
  • ') - .replace(/\n/g, '\n'); - // Wrap consecutive bullet
  • in