no message

This commit is contained in:
hajimi
2026-06-12 17:34:00 +08:00
parent b9669269b5
commit bd95eed024
20 changed files with 1383 additions and 112 deletions
+208
View File
@@ -0,0 +1,208 @@
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
const escapeAttribute = (value: string) => escapeHtml(value)
const parseLink = (value: string) => {
return value.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_match, text: string, url: string) => {
const safeUrl = escapeAttribute(url)
return `<a href="${safeUrl}">${text}</a>`
})
}
const parseInlineMarkdown = (value: string) => {
let html = escapeHtml(value)
html = parseLink(html)
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/(^|[^\*])\*([^\*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
return html
}
export const renderMarkdownToHtml = (value: string) => {
const source = String(value || '').replace(/\r\n?/g, '\n').trim()
if (!source) return ''
const lines = source.split('\n')
const htmlParts: string[] = []
const paragraphLines: string[] = []
const codeLines: string[] = []
const quoteLines: string[] = []
const tableLines: string[] = []
let listType: 'ul' | 'ol' | '' = ''
let inCodeBlock = false
const flushParagraph = () => {
if (!paragraphLines.length) return
htmlParts.push(`<p>${paragraphLines.map((line) => parseInlineMarkdown(line)).join('<br />')}</p>`)
paragraphLines.length = 0
}
const closeList = () => {
if (!listType) return
htmlParts.push(`</${listType}>`)
listType = ''
}
const flushQuote = () => {
if (!quoteLines.length) return
htmlParts.push(
`<blockquote>${quoteLines.map((line) => parseInlineMarkdown(line)).join('<br />')}</blockquote>`
)
quoteLines.length = 0
}
const flushCodeBlock = () => {
if (!codeLines.length) return
htmlParts.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
codeLines.length = 0
}
const parseTableCells = (line: string) => {
const cells = line
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
return cells.filter((_cell, index) => index < cells.length)
}
const isTableRow = (line: string) => /^\|.*\|$/.test(line) && parseTableCells(line).length >= 2
const isTableSeparatorLine = (line: string) => {
if (!isTableRow(line)) return false
return parseTableCells(line).every((cell) => /^:?-{3,}:?$/.test(cell))
}
const flushTable = () => {
if (!tableLines.length) return
if (tableLines.length >= 2 && isTableSeparatorLine(tableLines[1])) {
const headers = parseTableCells(tableLines[0])
const bodyRows = tableLines.slice(2).map(parseTableCells).filter((cells) => cells.length > 0)
const thead = `<thead><tr>${headers.map((cell) => `<th>${parseInlineMarkdown(cell)}</th>`).join('')}</tr></thead>`
const tbody = bodyRows.length
? `<tbody>${bodyRows
.map((cells) => `<tr>${cells.map((cell) => `<td>${parseInlineMarkdown(cell)}</td>`).join('')}</tr>`)
.join('')}</tbody>`
: ''
htmlParts.push(`<table>${thead}${tbody}</table>`)
} else {
htmlParts.push(`<p>${tableLines.map((line) => parseInlineMarkdown(line)).join('<br />')}</p>`)
}
tableLines.length = 0
}
for (const rawLine of lines) {
const line = rawLine.trim()
if (/^```/.test(line)) {
flushParagraph()
closeList()
flushQuote()
flushTable()
if (inCodeBlock) {
flushCodeBlock()
}
inCodeBlock = !inCodeBlock
continue
}
if (inCodeBlock) {
codeLines.push(rawLine)
continue
}
if (!line) {
flushParagraph()
closeList()
flushQuote()
flushTable()
continue
}
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/)
if (headingMatch) {
flushParagraph()
closeList()
flushQuote()
flushTable()
const level = Math.min(6, headingMatch[1].length)
htmlParts.push(`<h${level}>${parseInlineMarkdown(headingMatch[2])}</h${level}>`)
continue
}
const quoteMatch = line.match(/^>\s?(.*)$/)
if (quoteMatch) {
flushParagraph()
closeList()
flushTable()
quoteLines.push(quoteMatch[1])
continue
}
flushQuote()
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line)) {
flushParagraph()
closeList()
flushTable()
htmlParts.push('<hr />')
continue
}
const unorderedMatch = line.match(/^[-*+]\s+(.*)$/)
if (unorderedMatch) {
flushParagraph()
flushTable()
if (listType !== 'ul') {
closeList()
listType = 'ul'
htmlParts.push('<ul>')
}
htmlParts.push(`<li>${parseInlineMarkdown(unorderedMatch[1])}</li>`)
continue
}
const orderedMatch = line.match(/^\d+\.\s+(.*)$/)
if (orderedMatch) {
flushParagraph()
flushTable()
if (listType !== 'ol') {
closeList()
listType = 'ol'
htmlParts.push('<ol>')
}
htmlParts.push(`<li>${parseInlineMarkdown(orderedMatch[1])}</li>`)
continue
}
if (isTableRow(line)) {
flushParagraph()
closeList()
flushQuote()
tableLines.push(line)
continue
}
flushTable()
closeList()
paragraphLines.push(line)
}
flushParagraph()
closeList()
flushQuote()
flushTable()
if (inCodeBlock) {
flushCodeBlock()
}
return htmlParts.join('')
}
+29
View File
@@ -0,0 +1,29 @@
type MatchLike = {
competition_id?: number | string
league_name?: string
}
const WORLD_CUP_COMPETITION_ID = 61
const WORLD_CUP_SOURCE_UTC_OFFSET_SECONDS = 8 * 60 * 60
export const isWorldCupMatch = (match?: MatchLike | null): boolean => {
if (!match) return false
return Number(match.competition_id || 0) === WORLD_CUP_COMPETITION_ID
|| String(match.league_name || '') === '世界杯'
}
export const resolveMatchTimestamp = (timestamp: number | string | null | undefined, match?: MatchLike | null): number => {
const seconds = Number(timestamp || 0)
if (!Number.isFinite(seconds) || seconds <= 0) return 0
// World Cup source fields are UTC clock values saved as server-local Unix seconds.
if (isWorldCupMatch(match)) return seconds + WORLD_CUP_SOURCE_UTC_OFFSET_SECONDS
return seconds
}
export const getLocalMatchDate = (timestamp: number | string | null | undefined, match?: MatchLike | null): Date | null => {
const seconds = resolveMatchTimestamp(timestamp, match)
if (!seconds) return null
return new Date(seconds * 1000)
}