style(tracking-questions): normalize loose markdown in answer content

Repair detached headings, missing list/heading spaces, and pipe-only tables in
answer text before rendering, and style the markdown preview so headings,
lists, tables, and emphasis match the rest of the detail view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 10:18:35 +08:00
parent 2c4f7d6fcf
commit 05aa982f76
@@ -740,6 +740,172 @@ function sanitizeAnswerBody(answerText: string | null | undefined): string | nul
return sanitized || null
}
function normalizeLooseMarkdownLine(line: string): string {
const headingNormalizedLine = line
.replace(/^[\u200b-\u200f\ufeff]+/, '')
.replace(/^([ \t]{0,12})(?:\\#|&#35;|&#x23;|)/i, '$1#')
const answerScopedHeading = headingNormalizedLine.replace(
/^([ \t]{0,12})#{1,2}(?!#)\s*(?=\S)/,
'$1### ',
)
return answerScopedHeading
.replace(/^([ \t]{0,12})(#{1,6})(?=\S)/, '$1$2 ')
.replace(/^([ \t]{0,12}#{1,6}\s+)#{1,6}\s*/, '$1')
.replace(/^([ \t]{0,12}#{1,6}\s+\d+[.)、])(?=\S)/, '$1 ')
.replace(/^(\s*)([-*+])(?=\S)/, '$1$2 ')
.replace(/^(\s*)(\d{1,3})([.)])(?=\S)/, '$1$2$3 ')
}
function countMarkdownTablePipes(line: string): number {
return (line.match(/\|/g) ?? []).length
}
function splitMarkdownTableCells(line: string): string[] {
return line
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
function isMarkdownTableSeparatorLine(line: string): boolean {
const normalized = line.trim()
if (!normalized.includes('|')) {
return false
}
const cells = splitMarkdownTableCells(normalized)
return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell))
}
function countMarkdownTableCells(line: string): number {
return splitMarkdownTableCells(line).length
}
function normalizeMarkdownTableLine(line: string, targetCellCount?: number): string {
let normalized = line.trim()
if (!normalized.startsWith('|')) {
normalized = `| ${normalized}`
}
if (!normalized.endsWith('|')) {
normalized = `${normalized} |`
}
if (targetCellCount && targetCellCount > 0) {
const cells = splitMarkdownTableCells(normalized)
if (isMarkdownTableSeparatorLine(normalized)) {
while (cells.length < targetCellCount) {
cells.push('--------')
}
} else {
while (cells.length < targetCellCount) {
cells.push('')
}
}
if (cells.length > targetCellCount) {
cells.length = targetCellCount
}
normalized = `| ${cells.join(' | ')} |`
}
return normalized.replace(/\s*\|\s*/g, ' | ').replace(/\s{2,}/g, ' ').trim()
}
function normalizeLooseMarkdownTables(content: string): string {
const detachedTableBlocks = content
.replace(/\|\|(?=\s*:?-{3,}:?\s*\|)/g, '|\n|')
.replace(
/(^|\n)(\s{0,3}#{1,6}\s+[^|\n]{1,80})\|(?=[^|\n]+\|[^\n]*\n\s*\|?\s*:?-{3,}:?\s*\|)/g,
'$1$2\n|',
)
const lines = detachedTableBlocks.split('\n').flatMap((line: string) => {
const separatorWithFirstRow = line
.trim()
.match(/^((?:\|\s*:?-{3,}:?\s*){2,}\|?)(\S.*)$/)
if (
!separatorWithFirstRow?.[1] ||
!separatorWithFirstRow[2] ||
/^\|*$/.test(separatorWithFirstRow[2].trim())
) {
return [line]
}
return [separatorWithFirstRow[1], separatorWithFirstRow[2]]
})
const normalizedLines: string[] = []
let inTable = false
let tableCellCount = 0
for (const line of lines) {
const previousIndex = normalizedLines.length - 1
if (isMarkdownTableSeparatorLine(line)) {
tableCellCount =
previousIndex >= 0
? Math.max(
countMarkdownTableCells(normalizedLines[previousIndex] ?? ''),
countMarkdownTableCells(line),
)
: countMarkdownTableCells(line)
if (
previousIndex >= 0 &&
countMarkdownTablePipes(normalizedLines[previousIndex] ?? '') >= 2
) {
normalizedLines[previousIndex] = normalizeMarkdownTableLine(
normalizedLines[previousIndex] ?? '',
tableCellCount,
)
}
normalizedLines.push(normalizeMarkdownTableLine(line, tableCellCount))
inTable = true
continue
}
if (inTable && countMarkdownTablePipes(line) >= 2) {
normalizedLines.push(normalizeMarkdownTableLine(line, tableCellCount))
continue
}
inTable = false
tableCellCount = 0
normalizedLines.push(line)
}
return normalizedLines.join('\n')
}
function normalizeAnswerMarkdown(content: string): string {
const headingMarkerNormalized = content
.replace(/(^|\n)[\u200b-\u200f\ufeff]*([ \t]{0,12})(?:\\#|&#35;|&#x23;|)/gi, '$1$2#')
.replace(/([^\n])(?=[\u200b-\u200f\ufeff]*[ \t]{0,12}(?:\\#|&#35;|&#x23;||#){1,6}\s*(?:\d{1,3}[.)、]|[一二三四五六七八九十]+[、..]|第[一二三四五六七八九十0-9]+))/gi, '$1\n\n')
const detachedHeadings = normalizeLooseMarkdownTables(headingMarkerNormalized)
.replace(
/([^\n])(?=[\u200b-\u200f\ufeff]*[ \t]{0,12}#{1,6}\s*(?:\d{1,3}[.)、]|[一二三四五六七八九十]+[、..]|第[一二三四五六七八九十0-9]+))/g,
'$1\n\n',
)
.replace(/([^\n])(?=#{2,6}\s*\S)/g, '$1\n\n')
const lines = detachedHeadings.split('\n')
let inFence = false
const normalizedLines = lines.map((line) => {
if (/^\s*(?:```|~~~)/.test(line)) {
inFence = !inFence
return line
}
if (inFence) {
return line
}
return normalizeLooseMarkdownLine(line)
})
return normalizedLines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function buildInlineCitationMarker(
sourceGroupIndex: number,
platform: MonitoringQuestionDetailPlatform | null,
@@ -854,12 +1020,14 @@ function formatAnswerContent(platform: MonitoringQuestionDetailPlatform | null):
})
: sanitized
return withInlineMarkers
const normalizedMarkdown = withInlineMarkers
.replace(createQwenSourceGroupStripPattern(), '')
.replace(createYuanbaoCitationStripPattern(), '')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
return normalizeAnswerMarkdown(normalizedMarkdown)
}
function collectDeepSeekInlineCitationOccurrences(
@@ -1721,6 +1889,76 @@ const contentCitationColumns = computed(() => [
margin-bottom: 18px;
}
.tracking-question-answer__content :deep(.markdown-preview) {
color: #111827;
font-size: 15px;
line-height: 1.85;
overflow-wrap: anywhere;
}
.tracking-question-answer__content :deep(.markdown-preview h1),
.tracking-question-answer__content :deep(.markdown-preview h2),
.tracking-question-answer__content :deep(.markdown-preview h3),
.tracking-question-answer__content :deep(.markdown-preview h4),
.tracking-question-answer__content :deep(.markdown-preview h5),
.tracking-question-answer__content :deep(.markdown-preview h6) {
margin: 20px 0 10px;
color: #0f172a;
line-height: 1.35;
letter-spacing: 0;
}
.tracking-question-answer__content :deep(.markdown-preview h1) {
font-size: 24px;
}
.tracking-question-answer__content :deep(.markdown-preview h2) {
font-size: 21px;
}
.tracking-question-answer__content :deep(.markdown-preview h3) {
font-size: 18px;
}
.tracking-question-answer__content :deep(.markdown-preview h4),
.tracking-question-answer__content :deep(.markdown-preview h5),
.tracking-question-answer__content :deep(.markdown-preview h6) {
font-size: 16px;
}
.tracking-question-answer__content :deep(.markdown-preview p),
.tracking-question-answer__content :deep(.markdown-preview ul),
.tracking-question-answer__content :deep(.markdown-preview ol),
.tracking-question-answer__content :deep(.markdown-preview blockquote),
.tracking-question-answer__content :deep(.markdown-preview table) {
margin: 0 0 14px;
}
.tracking-question-answer__content :deep(.markdown-preview ul),
.tracking-question-answer__content :deep(.markdown-preview ol) {
padding-left: 22px;
}
.tracking-question-answer__content :deep(.markdown-preview li + li) {
margin-top: 6px;
}
.tracking-question-answer__content :deep(.markdown-preview strong) {
color: #0f172a;
font-weight: 700;
}
.tracking-question-answer__content :deep(.markdown-preview hr) {
margin: 22px 0;
}
.tracking-question-answer__content :deep(.markdown-preview table) {
display: block;
max-width: 100%;
overflow-x: auto;
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}