tiny-markdown-editor 0.2.27 → 0.2.29

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/lib/TinyMDE.js CHANGED
@@ -136,7 +136,7 @@ class Editor {
136
136
  this.isRestoringHistory = false;
137
137
  }
138
138
  handleUndoRedoKey(e) {
139
- const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
139
+ const isMac = grammar_1.editorRegExp.macPlatform.test(navigator.platform);
140
140
  const ctrl = isMac ? e.metaKey : e.ctrlKey;
141
141
  if (ctrl && !e.altKey) {
142
142
  if (e.key === "z" || e.key === "Z") {
@@ -195,13 +195,14 @@ class Editor {
195
195
  this.e.addEventListener("focus", () => this.hasFocus = true);
196
196
  this.e.addEventListener("paste", (e) => this.handlePaste(e));
197
197
  this.e.addEventListener("drop", (e) => this.handleDrop(e));
198
+ this.e.addEventListener("click", (e) => this.handleClick(e));
198
199
  this.lineElements = this.e.childNodes;
199
200
  }
200
201
  setContent(content) {
201
202
  while (this.e.firstChild) {
202
203
  this.e.removeChild(this.e.firstChild);
203
204
  }
204
- this.lines = content.split(/(?:\r\n|\r|\n)/);
205
+ this.lines = content.split(grammar_1.editorRegExp.lineSplit);
205
206
  this.lineDirty = [];
206
207
  for (let lineNum = 0; lineNum < this.lines.length; lineNum++) {
207
208
  let le = document.createElement("div");
@@ -242,7 +243,7 @@ class Editor {
242
243
  }
243
244
  }
244
245
  replace(replacement, capture) {
245
- return replacement.replace(/(\${1,2})([0-9])/g, (str, p1, p2) => {
246
+ return replacement.replace(grammar_1.editorRegExp.replacementPlaceholder, (str, p1, p2) => {
246
247
  if (p1 === "$")
247
248
  return (0, grammar_1.htmlescape)(capture[parseInt(p2)]);
248
249
  else
@@ -250,17 +251,194 @@ class Editor {
250
251
  });
251
252
  }
252
253
  applyLineTypes() {
253
- for (let lineNum = 0; lineNum < this.lines.length; lineNum++) {
254
- if (this.lineDirty[lineNum]) {
255
- let contentHTML = this.replace(this.lineReplacements[lineNum], this.lineCaptures[lineNum]);
256
- this.lineElements[lineNum].className = this.lineTypes[lineNum];
257
- this.lineElements[lineNum].removeAttribute("style");
258
- this.lineElements[lineNum].innerHTML =
259
- contentHTML === "" ? "<br />" : contentHTML;
254
+ let lineNum = 0;
255
+ while (lineNum < this.lines.length) {
256
+ // A "block" whose text flows across several source lines (a paragraph, a multi-line
257
+ // blockquote, or a list item with wrapped/lazy-continuation lines) is rendered as one
258
+ // inline-parsing unit so that inline styles (bold, italic, ...) can span the soft line
259
+ // breaks between its lines.
260
+ if (this.isInlineGroupLeader(lineNum)) {
261
+ const leaderType = this.lineTypes[lineNum];
262
+ let end = lineNum;
263
+ while (end + 1 < this.lines.length &&
264
+ this.isInlineContinuation(end + 1, leaderType)) {
265
+ end++;
266
+ }
267
+ this.applyInlineGroup(lineNum, end);
268
+ lineNum = end + 1;
269
+ }
270
+ else {
271
+ this.applyLine(lineNum);
272
+ lineNum++;
260
273
  }
261
- this.lineElements[lineNum].dataset.lineNum = lineNum.toString();
262
274
  }
263
275
  }
276
+ /** A line that can begin a multi-line inline group (a paragraph-bearing block). */
277
+ isInlineGroupLeader(lineNum) {
278
+ const t = this.lineTypes[lineNum];
279
+ if (t === "TMPara" || t === "TMUL" || t === "TMOL")
280
+ return true;
281
+ // A blockquote line whose content is blank is a paragraph break inside the quote, so it
282
+ // doesn't start (or join) a group.
283
+ if (t === "TMBlockquote")
284
+ return !this.isBlankBlockquoteContent(lineNum);
285
+ return false;
286
+ }
287
+ /** Whether `lineNum` continues the inline group whose leader has type `leaderType`. */
288
+ isInlineContinuation(lineNum, leaderType) {
289
+ const t = this.lineTypes[lineNum];
290
+ // A plain paragraph line is a lazy continuation of the preceding block (paragraph, list item,
291
+ // or blockquote). Consecutive list markers (TMUL/TMOL), by contrast, each start a new item
292
+ // and therefore do NOT continue the group.
293
+ if (t === "TMPara")
294
+ return true;
295
+ if (t === "TMBlockquote" && leaderType === "TMBlockquote")
296
+ return !this.isBlankBlockquoteContent(lineNum);
297
+ return false;
298
+ }
299
+ /** True when a blockquote line carries no (non-whitespace) content, e.g. `>` or `> `. */
300
+ isBlankBlockquoteContent(lineNum) {
301
+ const cap = this.lineCaptures[lineNum];
302
+ const content = cap && cap[2] !== undefined ? cap[2] : "";
303
+ return grammar_1.editorRegExp.blankContent.test(content);
304
+ }
305
+ applyLine(lineNum) {
306
+ const el = this.lineElements[lineNum];
307
+ if (this.lineDirty[lineNum]) {
308
+ let contentHTML = this.replace(this.lineReplacements[lineNum], this.lineCaptures[lineNum]);
309
+ el.className = this.lineTypes[lineNum];
310
+ el.removeAttribute("style");
311
+ el.innerHTML = contentHTML === "" ? "<br />" : contentHTML;
312
+ // This line is no longer rendered as part of a multi-line inline group.
313
+ delete el.dataset.mlSig;
314
+ }
315
+ el.dataset.lineNum = lineNum.toString();
316
+ }
317
+ /**
318
+ * Renders a group of lines [start..end] that together form one inline-parsing unit. The inline
319
+ * content of each line (the `$$N` portion of its replacement — the whole line for a paragraph,
320
+ * the text after the marker for a list item or blockquote) is joined with newlines and parsed as
321
+ * a single unit, so inline styles can span the soft line breaks. The resulting HTML is split back
322
+ * into one fragment per line; any element still open at a line break is closed and re-opened so
323
+ * each line keeps its own well-formed block element. Per-line markers (list bullets, blockquote
324
+ * `>`) are preserved exactly. A single-line group (start === end) renders identically to before.
325
+ */
326
+ applyInlineGroup(start, end) {
327
+ // Signature of every input that affects this group's rendering (line types, replacements and
328
+ // captures — hence the marker prefixes and the inline content). Stored per line element so a
329
+ // group is re-rendered whenever its content, a sibling line, or its boundaries change (e.g. on
330
+ // a split/merge) even if the individual line wasn't separately marked dirty.
331
+ const contents = [];
332
+ let sigSource = `${end - start}`;
333
+ for (let i = start; i <= end; i++) {
334
+ const idx = this.inlineContentIndex(this.lineReplacements[i]);
335
+ contents.push(this.lineCaptures[i][idx] !== undefined ? this.lineCaptures[i][idx] : "");
336
+ sigSource += `\x1f${this.lineTypes[i]}\x1f${this.lineReplacements[i]}\x1f${JSON.stringify(this.lineCaptures[i])}`;
337
+ }
338
+ const sig = `${this.hashString(sigSource)}`;
339
+ let needsRender = false;
340
+ for (let i = start; i <= end; i++) {
341
+ if (this.lineDirty[i] ||
342
+ this.lineElements[i].dataset.mlSig !== sig) {
343
+ needsRender = true;
344
+ break;
345
+ }
346
+ }
347
+ if (needsRender) {
348
+ const fragments = this.splitInlineHTMLByLine(this.processInlineStyles(contents.join("\n")));
349
+ for (let i = start; i <= end; i++) {
350
+ const el = this.lineElements[i];
351
+ const frag = fragments[i - start] || "";
352
+ const contentHTML = this.replaceWithInline(this.lineReplacements[i], this.lineCaptures[i], frag);
353
+ el.className = this.lineTypes[i];
354
+ el.removeAttribute("style");
355
+ el.innerHTML = contentHTML === "" ? "<br />" : contentHTML;
356
+ el.dataset.mlSig = sig;
357
+ }
358
+ }
359
+ for (let i = start; i <= end; i++) {
360
+ this.lineElements[i].dataset.lineNum = i.toString();
361
+ }
362
+ }
363
+ /** Index of the inline-content placeholder (`$$N`) in a line replacement, or 0 if none. */
364
+ inlineContentIndex(replacement) {
365
+ const m = grammar_1.editorRegExp.inlineContentPlaceholder.exec(replacement);
366
+ return m ? parseInt(m[1]) : 0;
367
+ }
368
+ /**
369
+ * Like replace(), but uses a pre-computed inline fragment for the `$$N` (inline content)
370
+ * placeholder instead of inline-parsing the capture itself. Used for multi-line groups where the
371
+ * inline content was parsed jointly across all lines.
372
+ */
373
+ replaceWithInline(replacement, capture, inlineFragment) {
374
+ return replacement.replace(grammar_1.editorRegExp.replacementPlaceholder, (str, p1, p2) => {
375
+ if (p1 === "$")
376
+ return (0, grammar_1.htmlescape)(capture[parseInt(p2)]);
377
+ else
378
+ return `<span class="TMInlineFormatted">${inlineFragment}</span>`;
379
+ });
380
+ }
381
+ /**
382
+ * Splits inline HTML produced for a joined multi-line paragraph back into one fragment per
383
+ * source line (fragments are separated by the `\n` markers emitted by processInlineStyles).
384
+ * Inline elements that are still open at a line break are closed at the end of the fragment and
385
+ * re-opened at the start of the next one, so that every fragment is independently well-formed
386
+ * while the formatting visually continues across the lines.
387
+ */
388
+ splitInlineHTMLByLine(html) {
389
+ const fragments = [];
390
+ const openTags = []; // full opening-tag strings of currently open elements
391
+ let current = "";
392
+ let i = 0;
393
+ while (i < html.length) {
394
+ const ch = html[i];
395
+ if (ch === "\n") {
396
+ // Close currently open tags (in reverse) to finish this line's fragment ...
397
+ for (let t = openTags.length - 1; t >= 0; t--) {
398
+ current += `</${this.inlineTagName(openTags[t])}>`;
399
+ }
400
+ fragments.push(current);
401
+ // ... and re-open them (in order) at the start of the next line's fragment.
402
+ current = openTags.join("");
403
+ i++;
404
+ }
405
+ else if (ch === "<") {
406
+ const close = html.indexOf(">", i);
407
+ if (close < 0) {
408
+ // Defensive: malformed output, emit the remainder verbatim.
409
+ current += html.substr(i);
410
+ break;
411
+ }
412
+ const tag = html.substring(i, close + 1);
413
+ current += tag;
414
+ if (tag[1] === "/") {
415
+ openTags.pop();
416
+ }
417
+ else if (tag[tag.length - 2] !== "/") {
418
+ // Opening tag that isn't self-closing.
419
+ openTags.push(tag);
420
+ }
421
+ i = close + 1;
422
+ }
423
+ else {
424
+ current += ch;
425
+ i++;
426
+ }
427
+ }
428
+ fragments.push(current);
429
+ return fragments;
430
+ }
431
+ inlineTagName(openTag) {
432
+ const m = grammar_1.editorRegExp.inlineTagName.exec(openTag);
433
+ return m ? m[1] : "span";
434
+ }
435
+ hashString(s) {
436
+ let h = 5381;
437
+ for (let i = 0; i < s.length; i++) {
438
+ h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0;
439
+ }
440
+ return h;
441
+ }
264
442
  updateLineTypes() {
265
443
  let codeBlockType = false;
266
444
  let codeBlockSeqLength = 0;
@@ -398,6 +576,40 @@ class Editor {
398
576
  } while (headingLine >= 0 && this.lineTypes[headingLine] === "TMPara");
399
577
  }
400
578
  }
579
+ // GFM task list item: a list item whose content begins with a checkbox marker `[ ]`, `[x]`
580
+ // or `[X]` followed by whitespace (or the end of the line). The brackets and separator stay
581
+ // as ordinary TMMark text; only the inner character is rendered as a checkbox (via CSS), so
582
+ // the line's text content still equals its source and the cursor navigates it normally.
583
+ if (lineType === "TMUL" || lineType === "TMOL") {
584
+ const content = lineCapture[2] !== undefined ? lineCapture[2] : "";
585
+ const task = grammar_1.editorRegExp.taskListItem.exec(content);
586
+ if (task) {
587
+ const checked = task[2] !== " "; // [x] / [X] are checked, [ ] is not
588
+ const bulletClass = lineType === "TMUL" ? "TMMark_TMUL" : "TMMark_TMOL";
589
+ const boxClass = checked
590
+ ? "TMCheckbox TMCheckbox_checked"
591
+ : "TMCheckbox TMCheckbox_unchecked";
592
+ // Synthetic capture: [full, bullet, "[", inner, "]", separator, inline content]. The
593
+ // inline content stays the last `$$N` group so inlineContentIndex()/applyInlineGroup()
594
+ // (and thus multi-line continuation) behave exactly as for a plain list item.
595
+ lineCapture = [
596
+ lineCapture[0],
597
+ lineCapture[1],
598
+ task[1],
599
+ task[2],
600
+ task[3],
601
+ task[4],
602
+ task[5],
603
+ ];
604
+ lineReplacement =
605
+ `<span class="TMMark ${bulletClass}">$1</span>` +
606
+ `<span class="TMMark TMMark_TMTask">$2</span>` +
607
+ `<span class="${boxClass}">$3</span>` +
608
+ `<span class="TMMark TMMark_TMTask">$4</span>` +
609
+ `<span class="TMMark TMMark_TMTask">$5</span>` +
610
+ `$$6`;
611
+ }
612
+ }
401
613
  if (this.lineTypes[lineNum] !== lineType) {
402
614
  this.lineTypes[lineNum] = lineType;
403
615
  this.lineDirty[lineNum] = true;
@@ -499,7 +711,7 @@ class Editor {
499
711
  beginning = focus;
500
712
  end = anchor;
501
713
  }
502
- let insertedLines = text.split(/(?:\r\n|\r|\n)/);
714
+ let insertedLines = text.split(grammar_1.editorRegExp.lineSplit);
503
715
  let lineBefore = this.lines[beginning.row].substr(0, beginning.col);
504
716
  let lineEnd = this.lines[end.row].substr(end.col);
505
717
  insertedLines[0] = lineBefore.concat(insertedLines[0]);
@@ -534,13 +746,13 @@ class Editor {
534
746
  this.setSelection(focus, anchor);
535
747
  }
536
748
  addEventListener(type, listener) {
537
- if (type.match(/^(?:change|input)$/i)) {
749
+ if (type.match(grammar_1.editorRegExp.changeEvent)) {
538
750
  this.listeners.change.push(listener);
539
751
  }
540
- if (type.match(/^(?:selection|selectionchange)$/i)) {
752
+ if (type.match(grammar_1.editorRegExp.selectionEvent)) {
541
753
  this.listeners.selection.push(listener);
542
754
  }
543
- if (type.match(/^(?:drop)$/i)) {
755
+ if (type.match(grammar_1.editorRegExp.dropEvent)) {
544
756
  this.listeners.drop.push(listener);
545
757
  }
546
758
  }
@@ -663,7 +875,7 @@ class Editor {
663
875
  if (capture) {
664
876
  if (capture[2]) {
665
877
  if (continuableType === "TMOL") {
666
- capture[1] = capture[1].replace(/\d{1,9}/, (result) => {
878
+ capture[1] = capture[1].replace(grammar_1.editorRegExp.orderedListNumber, (result) => {
667
879
  return (parseInt(result) + 1).toString();
668
880
  });
669
881
  }
@@ -728,12 +940,59 @@ class Editor {
728
940
  event.preventDefault();
729
941
  this.fireDrop(event.dataTransfer);
730
942
  }
943
+ /**
944
+ * Toggles a task list checkbox when its rendered box is clicked. Only the single inner character
945
+ * of the marker (`[ ]` -> `[x]` and back) is changed; the edit is length-preserving, recorded in
946
+ * the undo history, and fires a `change` event, mirroring setCommandState's line-edit flow.
947
+ */
948
+ handleClick(event) {
949
+ const target = event.target;
950
+ const box = target && target.closest ? target.closest(".TMCheckbox") : null;
951
+ if (!box)
952
+ return; // Not a checkbox click — let the default caret placement happen.
953
+ // Walk up to the line element (a direct child of the editor) to find its source row.
954
+ let lineEl = box;
955
+ while (lineEl && lineEl.parentNode !== this.e) {
956
+ lineEl = lineEl.parentNode;
957
+ }
958
+ if (!lineEl || lineEl.dataset.lineNum === undefined)
959
+ return;
960
+ const row = parseInt(lineEl.dataset.lineNum, 10);
961
+ if (isNaN(row) || row < 0 || row >= this.lines.length)
962
+ return;
963
+ const col = this.computeColumn(box, 0);
964
+ if (col === null)
965
+ return;
966
+ const ch = this.lines[row].charAt(col);
967
+ if (ch !== " " && ch !== "x" && ch !== "X")
968
+ return; // Defensive: not actually a checkbox char.
969
+ event.preventDefault();
970
+ if (!this.isRestoringHistory)
971
+ this.pushHistory();
972
+ this.clearDirtyFlag();
973
+ const replacement = ch === " " ? "x" : " ";
974
+ this.lines[row] = this.lines[row].substr(0, col) + replacement + this.lines[row].substr(col + 1);
975
+ this.lineDirty[row] = true;
976
+ this.updateFormatting();
977
+ this.fireChange();
978
+ }
731
979
  processInlineStyles(originalString) {
732
980
  let processed = "";
733
981
  let stack = [];
734
982
  let offset = 0;
735
983
  let string = originalString;
736
984
  outer: while (string) {
985
+ // Soft line break: when a paragraph that spans several source lines is parsed as one
986
+ // joined string, the lines are separated by \n. Preserve it as a newline marker in the
987
+ // output; splitInlineHTMLByLine() later splits the result back into per-line fragments.
988
+ // No inline rule starts on a newline (NotTriggerChar and `.` both exclude it), so handling
989
+ // it here keeps the tokenizer from stalling.
990
+ if (string.charCodeAt(0) === 10) {
991
+ processed += "\n";
992
+ string = string.substr(1);
993
+ offset += 1;
994
+ continue outer;
995
+ }
737
996
  // Process simple rules (non-delimiter)
738
997
  for (let rule of ["escape", "code", "autolink", "html"]) {
739
998
  if (this.mergedInlineGrammar[rule]) {
@@ -741,7 +1000,7 @@ class Editor {
741
1000
  if (cap) {
742
1001
  string = string.substr(cap[0].length);
743
1002
  offset += cap[0].length;
744
- processed += this.mergedInlineGrammar[rule].replacement.replace(/\$([1-9])/g, (str, p1) => (0, grammar_1.htmlescape)(cap[p1]));
1003
+ processed += this.mergedInlineGrammar[rule].replacement.replace(grammar_1.editorRegExp.inlineReplacementPlaceholder, (str, p1) => (0, grammar_1.htmlescape)(cap[p1]));
745
1004
  continue outer;
746
1005
  }
747
1006
  }
@@ -753,11 +1012,24 @@ class Editor {
753
1012
  if (cap) {
754
1013
  string = string.substr(cap[0].length);
755
1014
  offset += cap[0].length;
756
- processed += this.mergedInlineGrammar[rule].replacement.replace(/\$([1-9])/g, (str, p1) => (0, grammar_1.htmlescape)(cap[p1]));
1015
+ processed += this.mergedInlineGrammar[rule].replacement.replace(grammar_1.editorRegExp.inlineReplacementPlaceholder, (str, p1) => (0, grammar_1.htmlescape)(cap[p1]));
757
1016
  continue outer;
758
1017
  }
759
1018
  }
760
1019
  }
1020
+ // Check for GFM extended (non-delimited) autolinks: bare URLs (www.…, http(s)://…, ftp://…),
1021
+ // email addresses, and mailto:/xmpp: links. These are only recognized at the start of the
1022
+ // string or immediately after whitespace or one of the delimiters `*`, `_`, `~`, `(`.
1023
+ const precedingChar = offset > 0 ? originalString[offset - 1] : "";
1024
+ if (precedingChar === "" || grammar_1.editorRegExp.autolinkPrecedingChar.test(precedingChar)) {
1025
+ let autolink = this.parseAutolinkExtension(string);
1026
+ if (autolink) {
1027
+ processed = `${processed}${autolink.output}`;
1028
+ string = string.substr(autolink.charCount);
1029
+ offset += autolink.charCount;
1030
+ continue outer;
1031
+ }
1032
+ }
761
1033
  // Check for links / images
762
1034
  let potentialLink = string.match(this.mergedInlineGrammar.linkOpen.regexp);
763
1035
  let potentialImage = string.match(this.mergedInlineGrammar.imageOpen.regexp);
@@ -771,7 +1043,7 @@ class Editor {
771
1043
  }
772
1044
  }
773
1045
  // Check for em / strong delimiters
774
- let cap = /(^\*+)|(^_+)/.exec(string);
1046
+ let cap = grammar_1.editorRegExp.emphasisDelimiter.exec(string);
775
1047
  if (cap) {
776
1048
  let delimCount = cap[0].length;
777
1049
  const delimString = cap[0];
@@ -781,8 +1053,8 @@ class Editor {
781
1053
  const following = offset + cap[0].length < originalString.length ? string : " ";
782
1054
  const punctuationFollows = following.match(grammar_1.punctuationLeading);
783
1055
  const punctuationPrecedes = preceding.match(grammar_1.punctuationTrailing);
784
- const whitespaceFollows = following.match(/^\s/);
785
- const whitespacePrecedes = preceding.match(/\s$/);
1056
+ const whitespaceFollows = following.match(grammar_1.editorRegExp.leadingWhitespace);
1057
+ const whitespacePrecedes = preceding.match(grammar_1.editorRegExp.trailingWhitespace);
786
1058
  let canOpen = !whitespaceFollows && (!punctuationFollows || !!whitespacePrecedes || !!punctuationPrecedes);
787
1059
  let canClose = !whitespacePrecedes && (!punctuationPrecedes || !!whitespaceFollows || !!punctuationFollows);
788
1060
  if (currentDelimiter === "_" && canOpen && canClose) {
@@ -835,7 +1107,7 @@ class Editor {
835
1107
  continue outer;
836
1108
  }
837
1109
  // Check for strikethrough delimiter
838
- cap = /^~~/.exec(string);
1110
+ cap = grammar_1.editorRegExp.strikethroughDelimiter.exec(string);
839
1111
  if (cap) {
840
1112
  let consumed = false;
841
1113
  let stackPointer = stack.length - 1;
@@ -872,7 +1144,7 @@ class Editor {
872
1144
  if (cap) {
873
1145
  string = string.substr(cap[0].length);
874
1146
  offset += cap[0].length;
875
- processed += this.mergedInlineGrammar.default.replacement.replace(/\$([1-9])/g, (str, p1) => (0, grammar_1.htmlescape)(cap[p1]));
1147
+ processed += this.mergedInlineGrammar.default.replacement.replace(grammar_1.editorRegExp.inlineReplacementPlaceholder, (str, p1) => (0, grammar_1.htmlescape)(cap[p1]));
876
1148
  continue outer;
877
1149
  }
878
1150
  throw "Infinite loop!";
@@ -1059,6 +1331,118 @@ class Editor {
1059
1331
  }
1060
1332
  });
1061
1333
  }
1334
+ /**
1335
+ * Attempts to parse a GFM extended (non-delimited) autolink at the start of `string`: a bare URL
1336
+ * (`www.…`, `http://…`, `https://…`, `ftp://…`), an email address, or a `mailto:`/`xmpp:` link.
1337
+ * The caller is responsible for the preceding-character boundary check. On success, returns the
1338
+ * rendered HTML (the matched text wrapped in a `TMAutolink` span, exactly like the content of an
1339
+ * angle-bracketed autolink) and the number of source characters consumed; otherwise `false`.
1340
+ */
1341
+ parseAutolinkExtension(string) {
1342
+ const match = this.matchExtendedAutolink(string);
1343
+ if (!match)
1344
+ return false;
1345
+ return {
1346
+ output: `<span class="TMAutolink">${(0, grammar_1.htmlescape)(match)}</span>`,
1347
+ charCount: match.length,
1348
+ };
1349
+ }
1350
+ /** Returns the longest extended-autolink substring at the start of `string`, or `false`. */
1351
+ matchExtendedAutolink(string) {
1352
+ // --- Bare URL / www autolink -----------------------------------------------------------------
1353
+ // www. is treated as part of the domain; an explicit scheme is stripped before the domain.
1354
+ const schemeCap = grammar_1.editorRegExp.autolinkScheme.exec(string);
1355
+ const isWww = grammar_1.editorRegExp.autolinkWww.test(string);
1356
+ if (schemeCap || isWww) {
1357
+ const prefixLen = schemeCap ? schemeCap[0].length : 0;
1358
+ const afterPrefix = string.substr(prefixLen);
1359
+ // A valid domain is dot-separated segments of alphanumerics, `_` and `-`, with at least one
1360
+ // dot and no underscore in the last two segments.
1361
+ const domainCap = grammar_1.editorRegExp.autolinkDomain.exec(afterPrefix);
1362
+ if (domainCap && this.isValidAutolinkDomain(domainCap[0])) {
1363
+ // After the domain, any run of non-space, non-`<` characters forms the path/query/fragment.
1364
+ const pathCap = grammar_1.editorRegExp.autolinkPath.exec(afterPrefix.substr(domainCap[0].length));
1365
+ const link = string.substr(0, prefixLen + domainCap[0].length + (pathCap ? pathCap[0].length : 0));
1366
+ const trimmed = this.trimAutolinkTrailing(link);
1367
+ // Guard against trailing punctuation trimming having eaten into the domain.
1368
+ if (trimmed.length >= prefixLen + domainCap[0].length)
1369
+ return trimmed;
1370
+ }
1371
+ }
1372
+ // --- mailto: / xmpp: autolink ----------------------------------------------------------------
1373
+ const protoCap = grammar_1.editorRegExp.autolinkProtocol.exec(string);
1374
+ if (protoCap) {
1375
+ const emailCap = this.matchAutolinkEmail(string.substr(protoCap[0].length));
1376
+ if (emailCap) {
1377
+ // xmpp addresses may carry a `/resource` suffix.
1378
+ let extra = "";
1379
+ if (grammar_1.editorRegExp.autolinkXmpp.test(string)) {
1380
+ const resCap = grammar_1.editorRegExp.autolinkXmppResource.exec(string.substr(protoCap[0].length + emailCap.length));
1381
+ if (resCap)
1382
+ extra = resCap[0];
1383
+ }
1384
+ return protoCap[0] + emailCap + extra;
1385
+ }
1386
+ }
1387
+ // --- Bare email autolink ---------------------------------------------------------------------
1388
+ const email = this.matchAutolinkEmail(string);
1389
+ if (email)
1390
+ return email;
1391
+ return false;
1392
+ }
1393
+ /** GFM domain validity: at least one dot and no underscore in the last two segments. */
1394
+ isValidAutolinkDomain(domain) {
1395
+ const segments = domain.split(".");
1396
+ if (segments.length < 2)
1397
+ return false;
1398
+ return !segments.slice(-2).some((seg) => seg.indexOf("_") >= 0);
1399
+ }
1400
+ /** Matches a bare email address at the start of `string` per the GFM extension, or `false`. */
1401
+ matchAutolinkEmail(string) {
1402
+ // Local part: alphanumerics plus `.`, `-`, `_`, `+`. Domain: dot-separated labels of
1403
+ // alphanumerics, `-` and `_`, with at least one dot and no `-`/`_` as the final character.
1404
+ const cap = grammar_1.editorRegExp.autolinkEmail.exec(string);
1405
+ if (!cap)
1406
+ return false;
1407
+ let email = cap[0];
1408
+ while (email.length && grammar_1.editorRegExp.emailTrailingChar.test(email))
1409
+ email = email.substr(0, email.length - 1);
1410
+ // Must still end in a complete TLD (i.e. retain a dot after the trimming).
1411
+ if (!grammar_1.editorRegExp.emailTld.test(email))
1412
+ return false;
1413
+ return email;
1414
+ }
1415
+ /**
1416
+ * Strips trailing characters that GFM excludes from an autolink: the punctuation `?!.,:*_~`, and
1417
+ * any unbalanced trailing `)` (more closing than opening parentheses in the link).
1418
+ */
1419
+ trimAutolinkTrailing(link) {
1420
+ let end = link.length;
1421
+ let changed = true;
1422
+ while (changed && end > 0) {
1423
+ changed = false;
1424
+ const c = link[end - 1];
1425
+ if ("?!.,:*_~".indexOf(c) >= 0) {
1426
+ end--;
1427
+ changed = true;
1428
+ }
1429
+ else if (c === ")") {
1430
+ let open = 0;
1431
+ let close = 0;
1432
+ for (let i = 0; i < end; i++) {
1433
+ if (link[i] === "(")
1434
+ open++;
1435
+ else if (link[i] === ")")
1436
+ close++;
1437
+ }
1438
+ if (close > open) {
1439
+ end--;
1440
+ changed = true;
1441
+ }
1442
+ }
1443
+ }
1444
+ return link.substr(0, end);
1445
+ }
1062
1446
  parseLinkOrImage(originalString, isImage) {
1063
1447
  // Skip the opening bracket
1064
1448
  let textOffset = isImage ? 2 : 1;
@@ -1099,7 +1483,7 @@ class Editor {
1099
1483
  continue textOuter;
1100
1484
  }
1101
1485
  // Check for closing bracket
1102
- if (string.match(/^\]/)) {
1486
+ if (string.match(grammar_1.editorRegExp.linkClosingBracket)) {
1103
1487
  bracketLevel--;
1104
1488
  if (bracketLevel === 0) {
1105
1489
  linkText = originalString.substr(textOffset, currentOffset - textOffset);
@@ -1144,7 +1528,7 @@ class Editor {
1144
1528
  inlineOuter: while (currentOffset < originalString.length && parenthesisLevel > 0) {
1145
1529
  let string = originalString.substr(currentOffset);
1146
1530
  // Process whitespace
1147
- let cap = /^\s+/.exec(string);
1531
+ let cap = grammar_1.editorRegExp.leadingWhitespaceRun.exec(string);
1148
1532
  if (cap) {
1149
1533
  switch (linkDetails.length) {
1150
1534
  case 0:
@@ -1154,7 +1538,7 @@ class Editor {
1154
1538
  linkDetails.push(cap[0]);
1155
1539
  break;
1156
1540
  case 2:
1157
- if (linkDetails[0].match(/</)) {
1541
+ if (linkDetails[0].match(grammar_1.editorRegExp.containsOpenAngle)) {
1158
1542
  linkDetails[1] = linkDetails[1].concat(cap[0]);
1159
1543
  }
1160
1544
  else {
@@ -1211,7 +1595,7 @@ class Editor {
1211
1595
  continue inlineOuter;
1212
1596
  }
1213
1597
  // Process opening angle bracket
1214
- if (linkDetails.length < 2 && string.match(/^</)) {
1598
+ if (linkDetails.length < 2 && string.match(grammar_1.editorRegExp.openingAngle)) {
1215
1599
  if (linkDetails.length === 0)
1216
1600
  linkDetails.push("");
1217
1601
  linkDetails[0] = linkDetails[0].concat("<");
@@ -1219,7 +1603,7 @@ class Editor {
1219
1603
  continue inlineOuter;
1220
1604
  }
1221
1605
  // Process closing angle bracket
1222
- if ((linkDetails.length === 1 || linkDetails.length === 2) && string.match(/^>/)) {
1606
+ if ((linkDetails.length === 1 || linkDetails.length === 2) && string.match(grammar_1.editorRegExp.closingAngle)) {
1223
1607
  if (linkDetails.length === 1)
1224
1608
  linkDetails.push("");
1225
1609
  linkDetails.push(">");
@@ -1227,7 +1611,7 @@ class Editor {
1227
1611
  continue inlineOuter;
1228
1612
  }
1229
1613
  // Process non-parenthesis delimiter for title
1230
- cap = /^["']/.exec(string);
1614
+ cap = grammar_1.editorRegExp.titleQuote.exec(string);
1231
1615
  if (cap && (linkDetails.length === 0 || linkDetails.length === 1 || linkDetails.length === 4)) {
1232
1616
  while (linkDetails.length < 4)
1233
1617
  linkDetails.push("");
@@ -1243,7 +1627,7 @@ class Editor {
1243
1627
  continue inlineOuter;
1244
1628
  }
1245
1629
  // Process opening parenthesis
1246
- if (string.match(/^\(/)) {
1630
+ if (string.match(grammar_1.editorRegExp.openingParen)) {
1247
1631
  switch (linkDetails.length) {
1248
1632
  case 0:
1249
1633
  linkDetails.push("");
@@ -1251,7 +1635,7 @@ class Editor {
1251
1635
  linkDetails.push("");
1252
1636
  case 2:
1253
1637
  linkDetails[1] = linkDetails[1].concat("(");
1254
- if (!linkDetails[0].match(/<$/))
1638
+ if (!linkDetails[0].match(grammar_1.editorRegExp.endsWithOpenAngle))
1255
1639
  parenthesisLevel++;
1256
1640
  break;
1257
1641
  case 3:
@@ -1273,11 +1657,11 @@ class Editor {
1273
1657
  continue inlineOuter;
1274
1658
  }
1275
1659
  // Process closing parenthesis
1276
- if (string.match(/^\)/)) {
1660
+ if (string.match(grammar_1.editorRegExp.closingParen)) {
1277
1661
  if (linkDetails.length <= 2) {
1278
1662
  while (linkDetails.length < 2)
1279
1663
  linkDetails.push("");
1280
- if (!linkDetails[0].match(/<$/))
1664
+ if (!linkDetails[0].match(grammar_1.editorRegExp.endsWithOpenAngle))
1281
1665
  parenthesisLevel--;
1282
1666
  if (parenthesisLevel > 0) {
1283
1667
  linkDetails[1] = linkDetails[1].concat(")");
@@ -1307,7 +1691,7 @@ class Editor {
1307
1691
  continue inlineOuter;
1308
1692
  }
1309
1693
  // Any old character
1310
- cap = /^./.exec(string);
1694
+ cap = grammar_1.editorRegExp.anyChar.exec(string);
1311
1695
  if (cap) {
1312
1696
  switch (linkDetails.length) {
1313
1697
  case 0:
@@ -1362,8 +1746,11 @@ class Editor {
1362
1746
  while (linkDetails.length < 7) {
1363
1747
  linkDetails.push("");
1364
1748
  }
1749
+ // Every literal mark fragment is HTML-escaped: linkDetails[0]/[2] hold the angle brackets of
1750
+ // an `<...>` destination, so leaving them raw would inject a stray `<`/`>` into the output —
1751
+ // which splitInlineHTMLByLine then mistakes for an HTML tag.
1365
1752
  return {
1366
- output: `<span class="TMMark TMMark_${type}">${opener}</span><span class="${type}">${this.processInlineStyles(linkText)}</span><span class="TMMark TMMark_${type}">](${linkDetails[0]}</span><span class="${type}Destination">${(0, grammar_1.htmlescape)(linkDetails[1])}</span><span class="TMMark TMMark_${type}">${linkDetails[2]}${linkDetails[3]}${linkDetails[4]}</span><span class="${type}Title">${(0, grammar_1.htmlescape)(linkDetails[5])}</span><span class="TMMark TMMark_${type}">${linkDetails[6]})</span>`,
1753
+ output: `<span class="TMMark TMMark_${type}">${opener}</span><span class="${type}">${this.processInlineStyles(linkText)}</span><span class="TMMark TMMark_${type}">](${(0, grammar_1.htmlescape)(linkDetails[0])}</span><span class="${type}Destination">${(0, grammar_1.htmlescape)(linkDetails[1])}</span><span class="TMMark TMMark_${type}">${(0, grammar_1.htmlescape)(linkDetails[2])}${(0, grammar_1.htmlescape)(linkDetails[3])}${(0, grammar_1.htmlescape)(linkDetails[4])}</span><span class="${type}Title">${(0, grammar_1.htmlescape)(linkDetails[5])}</span><span class="TMMark TMMark_${type}">${(0, grammar_1.htmlescape)(linkDetails[6])})</span>`,
1367
1754
  charCount: currentOffset,
1368
1755
  };
1369
1756
  }
@@ -1532,7 +1919,7 @@ class Editor {
1532
1919
  : { startCol: anchor.col, endCol: focus.col };
1533
1920
  let match = this.lines[focus.row]
1534
1921
  .substr(startCol, endCol - startCol)
1535
- .match(/^(?<leading>\s*).*\S(?<trailing>\s*)$/);
1922
+ .match(grammar_1.editorRegExp.surroundingWhitespace);
1536
1923
  if (match) {
1537
1924
  startCol += match.groups.leading.length;
1538
1925
  endCol -= match.groups.trailing.length;