@adeu/core 1.6.2 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/markup.ts CHANGED
@@ -1,323 +1,323 @@
1
- import { trim_common_context } from './diff.js';
2
- import { ModifyText } from './models.js';
3
-
4
- function _should_strip_markers(text: string, marker: string): boolean {
5
- if (!text.startsWith(marker) || !text.endsWith(marker)) return false;
6
- if (text.length < marker.length * 2) return false;
7
-
8
- const inner = text.substring(marker.length, text.length - marker.length);
9
- if (!inner) return false;
10
-
11
- if (inner.includes(marker)) return false;
12
- if (!/[a-zA-Z]/.test(inner)) return false;
13
-
14
- if (marker === '__' && /^\w+$/.test(inner)) return false;
15
- if (marker === '_') {
16
- if (inner.includes('_')) return false;
17
- if (/^[0-9_]+$/.test(inner)) return false;
18
- }
19
-
20
- return true;
21
- }
22
-
23
- function _strip_balanced_markers(text: string): [string, string, string] {
24
- let prefix_markup = '';
25
- let suffix_markup = '';
26
- let clean_text = text;
27
-
28
- const markers = ['**', '__', '_', '*'];
29
-
30
- for (const marker of markers) {
31
- if (_should_strip_markers(clean_text, marker)) {
32
- prefix_markup += marker;
33
- suffix_markup = marker + suffix_markup;
34
- clean_text = clean_text.substring(marker.length, clean_text.length - marker.length);
35
- break;
36
- }
37
- }
38
-
39
- return [prefix_markup, clean_text, suffix_markup];
40
- }
41
-
42
- export function _replace_smart_quotes(text: string): string {
43
- return text.replace(/“/g, '"').replace(/”/g, '"').replace(/‘/g, "'").replace(/’/g, "'");
44
- }
45
-
46
- function _find_safe_boundaries(text: string, start: number, end: number): [number, number] {
47
- let new_start = start;
48
- let new_end = end;
49
-
50
- const expand_if_unbalanced = (marker: string) => {
51
- const current_match = text.substring(new_start, new_end);
52
- const count = (current_match.match(new RegExp(marker.replace(/\*/g, '\\*'), 'g')) || []).length;
53
-
54
- if (count % 2 !== 0) {
55
- const suffix = text.substring(new_end);
56
- if (suffix.startsWith(marker)) {
57
- new_end += marker.length;
58
- return;
59
- }
60
- const prefix = text.substring(0, new_start);
61
- if (prefix.endsWith(marker)) {
62
- new_start -= marker.length;
63
- return;
64
- }
65
- }
66
- };
67
-
68
- for (let i = 0; i < 2; i++) {
69
- expand_if_unbalanced('**');
70
- expand_if_unbalanced('__');
71
- expand_if_unbalanced('_');
72
- expand_if_unbalanced('*');
73
- }
74
-
75
- return [new_start, new_end];
76
- }
77
-
78
- function _refine_match_boundaries(text: string, start: number, end: number): [number, number] {
79
- const markers = ['**', '__', '*', '_'];
80
- let current_text = text.substring(start, end);
81
- let best_start = start;
82
- let best_end = end;
83
-
84
- const countMarker = (str: string, mk: string) => (str.match(new RegExp(mk.replace(/\*/g, '\\*'), 'g')) || []).length;
85
-
86
- for (const marker of markers) {
87
- if (current_text.startsWith(marker)) {
88
- const current_score = countMarker(current_text, marker) % 2;
89
- const trimmed_text = current_text.substring(marker.length);
90
- const trimmed_score = countMarker(trimmed_text, marker) % 2;
91
-
92
- if (current_score === 1 && trimmed_score === 0) {
93
- best_start += marker.length;
94
- current_text = trimmed_text;
95
- }
96
- }
97
- }
98
-
99
- for (const marker of markers) {
100
- if (current_text.endsWith(marker)) {
101
- const current_score = countMarker(current_text, marker) % 2;
102
- const trimmed_text = current_text.substring(0, current_text.length - marker.length);
103
- const trimmed_score = countMarker(trimmed_text, marker) % 2;
104
-
105
- if (current_score === 1 && trimmed_score === 0) {
106
- best_end -= marker.length;
107
- current_text = trimmed_text;
108
- }
109
- }
110
- }
111
-
112
- return [best_start, best_end];
113
- }
114
-
115
- export function _make_fuzzy_regex(target_text: string): string {
116
- target_text = _replace_smart_quotes(target_text);
117
-
118
- const parts: string[] = [];
119
- const token_pattern = /(_+)|(\s+)|(['"])|([.,;:\/])/g;
120
-
121
- // Note: JS does not support atomic groups (?>...).
122
- // However, because we only match markdown characters * and _,
123
- // we can use a character class `[*_]*` which is mathematically equivalent
124
- // to `(?:\*\*|__|\*|_)*` but fundamentally immune to catastrophic backtracking!
125
- const md_noise = "[*_]*";
126
- const structural_noise = "(?:\\s*(?:[*+\\->]|\\d+\\.)\\s+|\\s*\\n\\s*)";
127
-
128
- const start_list_marker = "(?:[ \\t]*(?:[*+\\->]|\\d+\\.)\\s+)?";
129
- parts.push(start_list_marker);
130
- parts.push(md_noise);
131
-
132
- let last_idx = 0;
133
- let match;
134
-
135
- const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
136
-
137
- while ((match = token_pattern.exec(target_text)) !== null) {
138
- const literal = target_text.substring(last_idx, match.index);
139
- if (literal) {
140
- parts.push(escapeRegExp(literal));
141
- parts.push(md_noise);
142
- }
143
-
144
- const g_underscore = match[1];
145
- const g_space = match[2];
146
- const g_quote = match[3];
147
- const g_punct = match[4];
148
-
149
- if (g_underscore) {
150
- parts.push('_+');
151
- } else if (g_space) {
152
- if (g_space.includes('\n')) {
153
- parts.push(`(?:${structural_noise}|\\s+)+`);
154
- } else {
155
- parts.push('\\s+');
156
- }
157
- } else if (g_quote) {
158
- if (g_quote === "'") parts.push('[\u2018\u2019\']');
159
- else parts.push('["\u201c\u201d]');
160
- } else if (g_punct) {
161
- parts.push(escapeRegExp(g_punct));
162
- }
163
-
164
- parts.push(md_noise);
165
- last_idx = token_pattern.lastIndex;
166
- }
167
-
168
- const remaining = target_text.substring(last_idx);
169
- if (remaining) parts.push(escapeRegExp(remaining));
170
-
171
- return parts.join('');
172
- }
173
-
174
- export function _find_match_in_text(text: string, target: string): [number, number] {
175
- if (!target) return [-1, -1];
176
-
177
- let idx = text.indexOf(target);
178
- if (idx !== -1) return _find_safe_boundaries(text, idx, idx + target.length);
179
-
180
- const norm_text = _replace_smart_quotes(text);
181
- const norm_target = _replace_smart_quotes(target);
182
- idx = norm_text.indexOf(norm_target);
183
- if (idx !== -1) return _find_safe_boundaries(text, idx, idx + norm_target.length);
184
-
185
- try {
186
- const pattern = new RegExp(_make_fuzzy_regex(target));
187
- const match = pattern.exec(text);
188
- if (match) {
189
- const raw_start = match.index;
190
- const raw_end = match.index + match[0].length;
191
- const [refined_start, refined_end] = _refine_match_boundaries(text, raw_start, raw_end);
192
- return _find_safe_boundaries(text, refined_start, refined_end);
193
- }
194
- } catch (e) {
195
- // Ignore regex compilation errors from edge cases
196
- }
197
-
198
- return [-1, -1];
199
- }
200
-
201
- export function _build_critic_markup(
202
- target_text: string,
203
- new_text: string,
204
- comment: string | null | undefined,
205
- edit_index: number,
206
- include_index: boolean,
207
- highlight_only: boolean
208
- ): string {
209
- const parts: string[] = [];
210
-
211
- let [prefix_markup, clean_target, suffix_markup] = _strip_balanced_markers(target_text);
212
-
213
- let clean_new = new_text;
214
- if (prefix_markup && new_text) {
215
- if (new_text.startsWith(prefix_markup) && new_text.endsWith(suffix_markup)) {
216
- const inner_len = prefix_markup.length;
217
- clean_new = new_text.length > inner_len * 2 ? new_text.substring(inner_len, new_text.length - inner_len) : new_text;
218
- }
219
- }
220
-
221
- parts.push(prefix_markup);
222
-
223
- if (highlight_only) {
224
- parts.push(`{==${clean_target}==}`);
225
- } else {
226
- const has_target = Boolean(clean_target);
227
- const has_new = Boolean(clean_new);
228
-
229
- if (has_target && !has_new) parts.push(`{--${clean_target}--}`);
230
- else if (!has_target && has_new) parts.push(`{++${clean_new}++}`);
231
- else if (has_target && has_new) parts.push(`{--${clean_target}--}{++${clean_new}++}`);
232
- }
233
-
234
- parts.push(suffix_markup);
235
-
236
- const meta_parts: string[] = [];
237
- if (comment) meta_parts.push(comment);
238
- if (include_index) meta_parts.push(`[Edit:${edit_index}]`);
239
-
240
- if (meta_parts.length > 0) {
241
- parts.push(`{>>${meta_parts.join(' ')}<<}`);
242
- }
243
-
244
- return parts.join('');
245
- }
246
-
247
- export function apply_edits_to_markdown(
248
- markdown_text: string,
249
- edits: ModifyText[],
250
- include_index = false,
251
- highlight_only = false
252
- ): string {
253
- if (!edits || edits.length === 0) return markdown_text;
254
-
255
- const matched_edits: [number, number, string, ModifyText, number][] = [];
256
-
257
- for (let idx = 0; idx < edits.length; idx++) {
258
- const edit = edits[idx];
259
- const target = edit.target_text || '';
260
-
261
- if (!target) {
262
- continue;
263
- }
264
-
265
- const [start, end] = _find_match_in_text(markdown_text, target);
266
- if (start === -1) continue;
267
-
268
- const actual_matched_text = markdown_text.substring(start, end);
269
- matched_edits.push([start, end, actual_matched_text, edit, idx]);
270
- }
271
-
272
- const matched_edits_filtered: [number, number, string, ModifyText, number][] = [];
273
- const occupied_ranges: [number, number][] = [];
274
-
275
- matched_edits.sort((a, b) => a[4] - b[4]);
276
-
277
- for (const [start, end, actual_text, edit, orig_idx] of matched_edits) {
278
- let overlaps = false;
279
- for (const [occ_start, occ_end] of occupied_ranges) {
280
- if (start < occ_end && end > occ_start) {
281
- overlaps = true;
282
- break;
283
- }
284
- }
285
-
286
- if (!overlaps) {
287
- matched_edits_filtered.push([start, end, actual_text, edit, orig_idx]);
288
- occupied_ranges.push([start, end]);
289
- }
290
- }
291
-
292
- matched_edits_filtered.sort((a, b) => b[0] - a[0]);
293
-
294
- let result = markdown_text;
295
-
296
- for (const [start, end, actual_text, edit, orig_idx] of matched_edits_filtered) {
297
- const new_txt = edit.new_text || '';
298
- const [prefix_len, suffix_len] = trim_common_context(actual_text, new_txt);
299
-
300
- const unmodified_prefix = prefix_len > 0 ? actual_text.substring(0, prefix_len) : '';
301
- const unmodified_suffix = suffix_len > 0 ? actual_text.substring(actual_text.length - suffix_len) : '';
302
-
303
- const t_end = actual_text.length - suffix_len;
304
- const n_end = new_txt.length - suffix_len;
305
-
306
- const isolated_target = actual_text.substring(prefix_len, t_end);
307
- const isolated_new = new_txt.substring(prefix_len, n_end);
308
-
309
- const markup = _build_critic_markup(
310
- isolated_target,
311
- isolated_new,
312
- edit.comment,
313
- orig_idx,
314
- include_index,
315
- highlight_only
316
- );
317
-
318
- const full_replacement = unmodified_prefix + markup + unmodified_suffix;
319
- result = result.substring(0, start) + full_replacement + result.substring(end);
320
- }
321
-
322
- return result;
1
+ import { trim_common_context } from './diff.js';
2
+ import { ModifyText } from './models.js';
3
+
4
+ function _should_strip_markers(text: string, marker: string): boolean {
5
+ if (!text.startsWith(marker) || !text.endsWith(marker)) return false;
6
+ if (text.length < marker.length * 2) return false;
7
+
8
+ const inner = text.substring(marker.length, text.length - marker.length);
9
+ if (!inner) return false;
10
+
11
+ if (inner.includes(marker)) return false;
12
+ if (!/[a-zA-Z]/.test(inner)) return false;
13
+
14
+ if (marker === '__' && /^\w+$/.test(inner)) return false;
15
+ if (marker === '_') {
16
+ if (inner.includes('_')) return false;
17
+ if (/^[0-9_]+$/.test(inner)) return false;
18
+ }
19
+
20
+ return true;
21
+ }
22
+
23
+ function _strip_balanced_markers(text: string): [string, string, string] {
24
+ let prefix_markup = '';
25
+ let suffix_markup = '';
26
+ let clean_text = text;
27
+
28
+ const markers = ['**', '__', '_', '*'];
29
+
30
+ for (const marker of markers) {
31
+ if (_should_strip_markers(clean_text, marker)) {
32
+ prefix_markup += marker;
33
+ suffix_markup = marker + suffix_markup;
34
+ clean_text = clean_text.substring(marker.length, clean_text.length - marker.length);
35
+ break;
36
+ }
37
+ }
38
+
39
+ return [prefix_markup, clean_text, suffix_markup];
40
+ }
41
+
42
+ export function _replace_smart_quotes(text: string): string {
43
+ return text.replace(/“/g, '"').replace(/”/g, '"').replace(/‘/g, "'").replace(/’/g, "'");
44
+ }
45
+
46
+ function _find_safe_boundaries(text: string, start: number, end: number): [number, number] {
47
+ let new_start = start;
48
+ let new_end = end;
49
+
50
+ const expand_if_unbalanced = (marker: string) => {
51
+ const current_match = text.substring(new_start, new_end);
52
+ const count = (current_match.match(new RegExp(marker.replace(/\*/g, '\\*'), 'g')) || []).length;
53
+
54
+ if (count % 2 !== 0) {
55
+ const suffix = text.substring(new_end);
56
+ if (suffix.startsWith(marker)) {
57
+ new_end += marker.length;
58
+ return;
59
+ }
60
+ const prefix = text.substring(0, new_start);
61
+ if (prefix.endsWith(marker)) {
62
+ new_start -= marker.length;
63
+ return;
64
+ }
65
+ }
66
+ };
67
+
68
+ for (let i = 0; i < 2; i++) {
69
+ expand_if_unbalanced('**');
70
+ expand_if_unbalanced('__');
71
+ expand_if_unbalanced('_');
72
+ expand_if_unbalanced('*');
73
+ }
74
+
75
+ return [new_start, new_end];
76
+ }
77
+
78
+ function _refine_match_boundaries(text: string, start: number, end: number): [number, number] {
79
+ const markers = ['**', '__', '*', '_'];
80
+ let current_text = text.substring(start, end);
81
+ let best_start = start;
82
+ let best_end = end;
83
+
84
+ const countMarker = (str: string, mk: string) => (str.match(new RegExp(mk.replace(/\*/g, '\\*'), 'g')) || []).length;
85
+
86
+ for (const marker of markers) {
87
+ if (current_text.startsWith(marker)) {
88
+ const current_score = countMarker(current_text, marker) % 2;
89
+ const trimmed_text = current_text.substring(marker.length);
90
+ const trimmed_score = countMarker(trimmed_text, marker) % 2;
91
+
92
+ if (current_score === 1 && trimmed_score === 0) {
93
+ best_start += marker.length;
94
+ current_text = trimmed_text;
95
+ }
96
+ }
97
+ }
98
+
99
+ for (const marker of markers) {
100
+ if (current_text.endsWith(marker)) {
101
+ const current_score = countMarker(current_text, marker) % 2;
102
+ const trimmed_text = current_text.substring(0, current_text.length - marker.length);
103
+ const trimmed_score = countMarker(trimmed_text, marker) % 2;
104
+
105
+ if (current_score === 1 && trimmed_score === 0) {
106
+ best_end -= marker.length;
107
+ current_text = trimmed_text;
108
+ }
109
+ }
110
+ }
111
+
112
+ return [best_start, best_end];
113
+ }
114
+
115
+ export function _make_fuzzy_regex(target_text: string): string {
116
+ target_text = _replace_smart_quotes(target_text);
117
+
118
+ const parts: string[] = [];
119
+ const token_pattern = /(_+)|(\s+)|(['"])|([.,;:\/])/g;
120
+
121
+ // Note: JS does not support atomic groups (?>...).
122
+ // However, because we only match markdown characters * and _,
123
+ // we can use a character class `[*_]*` which is mathematically equivalent
124
+ // to `(?:\*\*|__|\*|_)*` but fundamentally immune to catastrophic backtracking!
125
+ const md_noise = "[*_]*";
126
+ const structural_noise = "(?:\\s*(?:[*+\\->]|\\d+\\.)\\s+|\\s*\\n\\s*)";
127
+
128
+ const start_list_marker = "(?:[ \\t]*(?:[*+\\->]|\\d+\\.)\\s+)?";
129
+ parts.push(start_list_marker);
130
+ parts.push(md_noise);
131
+
132
+ let last_idx = 0;
133
+ let match;
134
+
135
+ const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
136
+
137
+ while ((match = token_pattern.exec(target_text)) !== null) {
138
+ const literal = target_text.substring(last_idx, match.index);
139
+ if (literal) {
140
+ parts.push(escapeRegExp(literal));
141
+ parts.push(md_noise);
142
+ }
143
+
144
+ const g_underscore = match[1];
145
+ const g_space = match[2];
146
+ const g_quote = match[3];
147
+ const g_punct = match[4];
148
+
149
+ if (g_underscore) {
150
+ parts.push('_+');
151
+ } else if (g_space) {
152
+ if (g_space.includes('\n')) {
153
+ parts.push(`(?:${structural_noise}|\\s+)+`);
154
+ } else {
155
+ parts.push('\\s+');
156
+ }
157
+ } else if (g_quote) {
158
+ if (g_quote === "'") parts.push('[\u2018\u2019\']');
159
+ else parts.push('["\u201c\u201d]');
160
+ } else if (g_punct) {
161
+ parts.push(escapeRegExp(g_punct));
162
+ }
163
+
164
+ parts.push(md_noise);
165
+ last_idx = token_pattern.lastIndex;
166
+ }
167
+
168
+ const remaining = target_text.substring(last_idx);
169
+ if (remaining) parts.push(escapeRegExp(remaining));
170
+
171
+ return parts.join('');
172
+ }
173
+
174
+ export function _find_match_in_text(text: string, target: string): [number, number] {
175
+ if (!target) return [-1, -1];
176
+
177
+ let idx = text.indexOf(target);
178
+ if (idx !== -1) return _find_safe_boundaries(text, idx, idx + target.length);
179
+
180
+ const norm_text = _replace_smart_quotes(text);
181
+ const norm_target = _replace_smart_quotes(target);
182
+ idx = norm_text.indexOf(norm_target);
183
+ if (idx !== -1) return _find_safe_boundaries(text, idx, idx + norm_target.length);
184
+
185
+ try {
186
+ const pattern = new RegExp(_make_fuzzy_regex(target));
187
+ const match = pattern.exec(text);
188
+ if (match) {
189
+ const raw_start = match.index;
190
+ const raw_end = match.index + match[0].length;
191
+ const [refined_start, refined_end] = _refine_match_boundaries(text, raw_start, raw_end);
192
+ return _find_safe_boundaries(text, refined_start, refined_end);
193
+ }
194
+ } catch (e) {
195
+ // Ignore regex compilation errors from edge cases
196
+ }
197
+
198
+ return [-1, -1];
199
+ }
200
+
201
+ export function _build_critic_markup(
202
+ target_text: string,
203
+ new_text: string,
204
+ comment: string | null | undefined,
205
+ edit_index: number,
206
+ include_index: boolean,
207
+ highlight_only: boolean
208
+ ): string {
209
+ const parts: string[] = [];
210
+
211
+ let [prefix_markup, clean_target, suffix_markup] = _strip_balanced_markers(target_text);
212
+
213
+ let clean_new = new_text;
214
+ if (prefix_markup && new_text) {
215
+ if (new_text.startsWith(prefix_markup) && new_text.endsWith(suffix_markup)) {
216
+ const inner_len = prefix_markup.length;
217
+ clean_new = new_text.length > inner_len * 2 ? new_text.substring(inner_len, new_text.length - inner_len) : new_text;
218
+ }
219
+ }
220
+
221
+ parts.push(prefix_markup);
222
+
223
+ if (highlight_only) {
224
+ parts.push(`{==${clean_target}==}`);
225
+ } else {
226
+ const has_target = Boolean(clean_target);
227
+ const has_new = Boolean(clean_new);
228
+
229
+ if (has_target && !has_new) parts.push(`{--${clean_target}--}`);
230
+ else if (!has_target && has_new) parts.push(`{++${clean_new}++}`);
231
+ else if (has_target && has_new) parts.push(`{--${clean_target}--}{++${clean_new}++}`);
232
+ }
233
+
234
+ parts.push(suffix_markup);
235
+
236
+ const meta_parts: string[] = [];
237
+ if (comment) meta_parts.push(comment);
238
+ if (include_index) meta_parts.push(`[Edit:${edit_index}]`);
239
+
240
+ if (meta_parts.length > 0) {
241
+ parts.push(`{>>${meta_parts.join(' ')}<<}`);
242
+ }
243
+
244
+ return parts.join('');
245
+ }
246
+
247
+ export function apply_edits_to_markdown(
248
+ markdown_text: string,
249
+ edits: ModifyText[],
250
+ include_index = false,
251
+ highlight_only = false
252
+ ): string {
253
+ if (!edits || edits.length === 0) return markdown_text;
254
+
255
+ const matched_edits: [number, number, string, ModifyText, number][] = [];
256
+
257
+ for (let idx = 0; idx < edits.length; idx++) {
258
+ const edit = edits[idx];
259
+ const target = edit.target_text || '';
260
+
261
+ if (!target) {
262
+ continue;
263
+ }
264
+
265
+ const [start, end] = _find_match_in_text(markdown_text, target);
266
+ if (start === -1) continue;
267
+
268
+ const actual_matched_text = markdown_text.substring(start, end);
269
+ matched_edits.push([start, end, actual_matched_text, edit, idx]);
270
+ }
271
+
272
+ const matched_edits_filtered: [number, number, string, ModifyText, number][] = [];
273
+ const occupied_ranges: [number, number][] = [];
274
+
275
+ matched_edits.sort((a, b) => a[4] - b[4]);
276
+
277
+ for (const [start, end, actual_text, edit, orig_idx] of matched_edits) {
278
+ let overlaps = false;
279
+ for (const [occ_start, occ_end] of occupied_ranges) {
280
+ if (start < occ_end && end > occ_start) {
281
+ overlaps = true;
282
+ break;
283
+ }
284
+ }
285
+
286
+ if (!overlaps) {
287
+ matched_edits_filtered.push([start, end, actual_text, edit, orig_idx]);
288
+ occupied_ranges.push([start, end]);
289
+ }
290
+ }
291
+
292
+ matched_edits_filtered.sort((a, b) => b[0] - a[0]);
293
+
294
+ let result = markdown_text;
295
+
296
+ for (const [start, end, actual_text, edit, orig_idx] of matched_edits_filtered) {
297
+ const new_txt = edit.new_text || '';
298
+ const [prefix_len, suffix_len] = trim_common_context(actual_text, new_txt);
299
+
300
+ const unmodified_prefix = prefix_len > 0 ? actual_text.substring(0, prefix_len) : '';
301
+ const unmodified_suffix = suffix_len > 0 ? actual_text.substring(actual_text.length - suffix_len) : '';
302
+
303
+ const t_end = actual_text.length - suffix_len;
304
+ const n_end = new_txt.length - suffix_len;
305
+
306
+ const isolated_target = actual_text.substring(prefix_len, t_end);
307
+ const isolated_new = new_txt.substring(prefix_len, n_end);
308
+
309
+ const markup = _build_critic_markup(
310
+ isolated_target,
311
+ isolated_new,
312
+ edit.comment,
313
+ orig_idx,
314
+ include_index,
315
+ highlight_only
316
+ );
317
+
318
+ const full_replacement = unmodified_prefix + markup + unmodified_suffix;
319
+ result = result.substring(0, start) + full_replacement + result.substring(end);
320
+ }
321
+
322
+ return result;
323
323
  }