Spaces:
Runtime error
Runtime error
File size: 5,420 Bytes
46252cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | /**
* AST node for parsed WhatsApp message text.
* - text: literal string
* - bold / italic / strike: container with children (allows nesting)
* - code: inline `code`; value rendered literally, no link detection
* - codeblock: ```block```; value rendered literally with newlines preserved
*/
export type MessageNode =
| { type: 'text'; value: string }
| { type: 'bold'; children: MessageNode[] }
| { type: 'italic'; children: MessageNode[] }
| { type: 'strike'; children: MessageNode[] }
| { type: 'code'; value: string }
| { type: 'codeblock'; value: string };
const FORMATS: Record<string, 'bold' | 'italic' | 'strike'> = {
'*': 'bold',
'_': 'italic',
'~': 'strike',
};
const BOUNDARY_CHAR = /^[\s.,;:!?()[\]{}'"<>]$/;
/**
* Parse a WhatsApp-formatted text string into a list of MessageNode.
*
* Algorithm:
* 1. Extract code segments (triple-backtick blocks first, then single-backtick inline)
* by walking the string and emitting `codeblock` / `code` nodes for them; the
* remaining text segments are passed to the format parser.
* 2. The format parser does a recursive descent: it scans for the next opening
* marker (`*`, `_`, `~`) that has a valid boundary on the outside and a
* non-whitespace char immediately inside, finds the matching closing marker
* with the same boundary rules, and recurses on the inner content.
* 3. Unbalanced or boundary-violating markers fall through as literal text.
*/
export function parseMessageBody(input: string): MessageNode[] {
if (input.length === 0) return [];
// Step 1: peel off code segments, emit nodes between them.
const nodes: MessageNode[] = [];
let cursor = 0;
const flushText = (end: number) => {
if (end <= cursor) return;
const slice = input.slice(cursor, end);
nodes.push(...parseFormatting(slice));
cursor = end;
};
while (cursor < input.length) {
// Look for next ``` first (longer marker wins).
const tripleStart = input.indexOf('```', cursor);
const singleStart = findSingleBacktick(input, cursor);
let nextCode: 'triple' | 'single' | null = null;
let nextIdx = Infinity;
if (tripleStart !== -1 && tripleStart < nextIdx) {
nextCode = 'triple';
nextIdx = tripleStart;
}
if (singleStart !== -1 && singleStart < nextIdx) {
nextCode = 'single';
nextIdx = singleStart;
}
if (!nextCode) {
flushText(input.length);
break;
}
if (nextCode === 'triple') {
const closeIdx = input.indexOf('```', nextIdx + 3);
if (closeIdx === -1) {
// Unclosed: treat the rest as plain.
flushText(input.length);
break;
}
flushText(nextIdx);
const value = input.slice(nextIdx + 3, closeIdx);
nodes.push({ type: 'codeblock', value });
cursor = closeIdx + 3;
continue;
}
// single backtick
const closeIdx = input.indexOf('`', nextIdx + 1);
if (closeIdx === -1) {
flushText(input.length);
break;
}
flushText(nextIdx);
const value = input.slice(nextIdx + 1, closeIdx);
nodes.push({ type: 'code', value });
cursor = closeIdx + 1;
}
return nodes;
}
/**
* Find next single-backtick that is NOT part of a triple-backtick.
* Returns -1 if none.
*/
function findSingleBacktick(s: string, from: number): number {
let i = from;
while (i < s.length) {
const idx = s.indexOf('`', i);
if (idx === -1) return -1;
// Skip if part of a triple-backtick sequence.
if (s.slice(idx, idx + 3) === '```') {
i = idx + 3;
continue;
}
if (idx > 0 && s.slice(idx - 1, idx + 2) === '```') {
// The '`' is the middle of a triple backtick; skip past all three.
i = idx + 2;
continue;
}
return idx;
}
return -1;
}
/**
* Parse a text segment (no code in it) for *bold*, _italic_, ~strike~.
* Recursive: inner content is parsed again so *_a_* nests.
*/
function parseFormatting(input: string): MessageNode[] {
if (input.length === 0) return [];
for (let i = 0; i < input.length; i++) {
const ch = input[i];
const fmt = FORMATS[ch];
if (!fmt) continue;
// Boundary outside the opener: previous char must be a boundary or string-start.
const prev = i === 0 ? '' : input[i - 1];
if (prev !== '' && !BOUNDARY_CHAR.test(prev)) continue;
// Char immediately inside (right after the opener) must NOT be whitespace.
const inside = input[i + 1];
if (!inside || /\s/.test(inside)) continue;
// Find the matching closing marker.
for (let j = i + 1; j < input.length; j++) {
if (input[j] !== ch) continue;
// Char immediately before closer must NOT be whitespace.
const beforeCloser = input[j - 1];
if (/\s/.test(beforeCloser)) continue;
// Boundary after closer: must be boundary or string-end.
const after = j === input.length - 1 ? '' : input[j + 1];
if (after !== '' && !BOUNDARY_CHAR.test(after)) continue;
const before = input.slice(0, i);
const inner = input.slice(i + 1, j);
const rest = input.slice(j + 1);
return [
...(before ? [{ type: 'text', value: before } as MessageNode] : []),
{ type: fmt, children: parseFormatting(inner) },
...parseFormatting(rest),
];
}
// No matching closer for this opener — fall through to next character.
}
return [{ type: 'text', value: input }];
}
|