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 }];
}