|
| 1 | +<template> |
| 2 | + <p class="mb-4 leading-relaxed text-accent-primary"> |
| 3 | + <span v-for="(part, index) in parsedParts" :key="index"> |
| 4 | + <template v-if="part.type === 'text'"> |
| 5 | + {{ part.content }} |
| 6 | + </template> |
| 7 | + <template v-else-if="part.type === 'bold'"> |
| 8 | + <strong class="font-bold">{{ part.content }}</strong> |
| 9 | + </template> |
| 10 | + <template v-else-if="part.type === 'italic'"> |
| 11 | + <em class="italic">{{ part.content }}</em> |
| 12 | + </template> |
| 13 | + <template v-else-if="part.type === 'boldItalic'"> |
| 14 | + <strong class="font-bold"><em class="italic">{{ part.content }}</em></strong> |
| 15 | + </template> |
| 16 | + <template v-else-if="part.type === 'code'"> |
| 17 | + <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-sm font-mono text-accent-primary"> |
| 18 | + {{ part.content }} |
| 19 | + </code> |
| 20 | + </template> |
| 21 | + <template v-else-if="part.type === 'link'"> |
| 22 | + <a :href="part.url" class="text-accent-primary underline hover:text-accent-primary/80" target="_blank" rel="noopener noreferrer"> |
| 23 | + {{ part.text }} |
| 24 | + </a> |
| 25 | + </template> |
| 26 | + <template v-else-if="part.type === 'strikethrough'"> |
| 27 | + <del class="line-through">{{ part.content }}</del> |
| 28 | + </template> |
| 29 | + <template v-else-if="part.type === 'linebreak'"> |
| 30 | + <br /> |
| 31 | + </template> |
| 32 | + </span> |
| 33 | + </p> |
| 34 | +</template> |
| 35 | + |
| 36 | +<script setup lang="ts"> |
| 37 | +import { computed } from 'vue' |
| 38 | +
|
| 39 | +const props = defineProps<{ |
| 40 | + text: string |
| 41 | +}>() |
| 42 | +
|
| 43 | +interface TextPart { |
| 44 | + type: 'text' | 'bold' | 'italic' | 'boldItalic' | 'code' | 'link' | 'strikethrough' | 'linebreak' |
| 45 | + content?: string |
| 46 | + text?: string |
| 47 | + url?: string |
| 48 | +} |
| 49 | +
|
| 50 | +const parsedParts = computed((): TextPart[] => { |
| 51 | + const parts: TextPart[] = [] |
| 52 | + const text = props.text |
| 53 | + |
| 54 | + if (!text) { |
| 55 | + return parts |
| 56 | + } |
| 57 | +
|
| 58 | + // Helper function to add text part |
| 59 | + const addText = (content: string) => { |
| 60 | + if (content) { |
| 61 | + parts.push({ type: 'text', content }) |
| 62 | + } |
| 63 | + } |
| 64 | +
|
| 65 | + // Helper to find all pattern matches with their positions |
| 66 | + const findMatches = (regex: RegExp, type: string) => { |
| 67 | + const matches: Array<{ type: string; start: number; end: number; groups: RegExpMatchArray }> = [] |
| 68 | + let match: RegExpExecArray | null |
| 69 | + |
| 70 | + // Reset regex lastIndex |
| 71 | + regex.lastIndex = 0 |
| 72 | + |
| 73 | + while ((match = regex.exec(text)) !== null) { |
| 74 | + matches.push({ |
| 75 | + type, |
| 76 | + start: match.index, |
| 77 | + end: match.index + match[0].length, |
| 78 | + groups: match |
| 79 | + }) |
| 80 | + |
| 81 | + // Prevent infinite loop on zero-length matches |
| 82 | + if (match[0].length === 0) { |
| 83 | + regex.lastIndex++ |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + return matches |
| 88 | + } |
| 89 | +
|
| 90 | + // Find all matches for each pattern type |
| 91 | + // Order matters: process more specific patterns first (e.g., boldItalic before bold/italic) |
| 92 | + const allMatches: Array<{ type: string; start: number; end: number; groups: RegExpMatchArray }> = [] |
| 93 | + |
| 94 | + // Bold+Italic (***text*** or ___text___) |
| 95 | + allMatches.push(...findMatches(/(\*\*\*|___)(.+?)\1/g, 'boldItalic')) |
| 96 | + |
| 97 | + // Code (inline code - process first to avoid parsing inside code blocks) |
| 98 | + allMatches.push(...findMatches(/`([^`\n]+)`/g, 'code')) |
| 99 | + |
| 100 | + // Links ([text](url)) |
| 101 | + allMatches.push(...findMatches(/\[([^\]]+)\]\(([^)]+)\)/g, 'link')) |
| 102 | + |
| 103 | + // Strikethrough (~~text~~) |
| 104 | + allMatches.push(...findMatches(/~~(.+?)~~/g, 'strikethrough')) |
| 105 | + |
| 106 | + // Bold (**text** or __text__) |
| 107 | + // Note: We check these are not part of boldItalic by processing boldItalic first and filtering overlaps |
| 108 | + allMatches.push(...findMatches(/(\*\*|__)([^*_\n]+?)\1/g, 'bold')) |
| 109 | + |
| 110 | + // Italic (*text* or _text_) |
| 111 | + // Must have word boundary to avoid matching parts of bold or code |
| 112 | + allMatches.push(...findMatches(/(?<![*_])(?<!\w)(\*|_)([^*_\n]+?)\1(?![*_])(?!\w)/g, 'italic')) |
| 113 | + |
| 114 | + // Line breaks (two spaces + newline or double newline) |
| 115 | + allMatches.push(...findMatches(/ \n|\n\n/g, 'linebreak')) |
| 116 | +
|
| 117 | + // Sort matches by position |
| 118 | + allMatches.sort((a, b) => a.start - b.start) |
| 119 | +
|
| 120 | + // Remove overlapping matches (keep the longest match when overlaps occur) |
| 121 | + const filteredMatches: Array<{ type: string; start: number; end: number; groups: RegExpMatchArray }> = [] |
| 122 | + for (let i = 0; i < allMatches.length; i++) { |
| 123 | + const current = allMatches[i] as { type: string; start: number; end: number; groups: RegExpMatchArray } |
| 124 | + let shouldAdd = true |
| 125 | + |
| 126 | + // Check if current overlaps with any already filtered match |
| 127 | + for (let j = filteredMatches.length - 1; j >= 0; j--) { |
| 128 | + const existing = filteredMatches[j] as { type: string; start: number; end: number; groups: RegExpMatchArray } |
| 129 | + const overlaps = !(current.end <= existing.start || current.start >= existing.end) |
| 130 | + |
| 131 | + if (overlaps) { |
| 132 | + // If current is longer, replace the existing match |
| 133 | + const currentLength = current.end - current.start |
| 134 | + const existingLength = existing.end - existing.start |
| 135 | + |
| 136 | + if (currentLength > existingLength) { |
| 137 | + filteredMatches.splice(j, 1) |
| 138 | + } else { |
| 139 | + // Current is shorter or equal, don't add it |
| 140 | + shouldAdd = false |
| 141 | + break |
| 142 | + } |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + if (shouldAdd) { |
| 147 | + filteredMatches.push(current) |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + // Re-sort after filtering (positions may have changed) |
| 152 | + filteredMatches.sort((a, b) => a.start - b.start) |
| 153 | +
|
| 154 | + // Build parts array |
| 155 | + let lastPos = 0 |
| 156 | + |
| 157 | + for (const match of filteredMatches) { |
| 158 | + // Add text before this match |
| 159 | + if (match.start > lastPos) { |
| 160 | + addText(text.substring(lastPos, match.start)) |
| 161 | + } |
| 162 | + |
| 163 | + // Add the matched part |
| 164 | + switch (match.type) { |
| 165 | + case 'boldItalic': |
| 166 | + parts.push({ type: 'boldItalic', content: match.groups[2] }) |
| 167 | + break |
| 168 | + case 'bold': |
| 169 | + parts.push({ type: 'bold', content: match.groups[2] }) |
| 170 | + break |
| 171 | + case 'italic': |
| 172 | + parts.push({ type: 'italic', content: match.groups[2] }) |
| 173 | + break |
| 174 | + case 'code': |
| 175 | + parts.push({ type: 'code', content: match.groups[1] }) |
| 176 | + break |
| 177 | + case 'link': |
| 178 | + parts.push({ type: 'link', text: match.groups[1], url: match.groups[2] }) |
| 179 | + break |
| 180 | + case 'strikethrough': |
| 181 | + parts.push({ type: 'strikethrough', content: match.groups[1] }) |
| 182 | + break |
| 183 | + case 'linebreak': |
| 184 | + parts.push({ type: 'linebreak' }) |
| 185 | + break |
| 186 | + } |
| 187 | + |
| 188 | + lastPos = match.end |
| 189 | + } |
| 190 | + |
| 191 | + // Add remaining text |
| 192 | + if (lastPos < text.length) { |
| 193 | + addText(text.substring(lastPos)) |
| 194 | + } |
| 195 | +
|
| 196 | + // If no parts were created, add the whole text as plain text |
| 197 | + if (parts.length === 0) { |
| 198 | + parts.push({ type: 'text', content: text }) |
| 199 | + } |
| 200 | +
|
| 201 | + return parts |
| 202 | +}) |
| 203 | +</script> |
| 204 | + |
0 commit comments