obsidian-mcp-server 3.5.5 → 3.6.0

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.
Files changed (50) hide show
  1. package/AGENTS.md +4 -2
  2. package/CLAUDE.md +4 -2
  3. package/README.md +8 -8
  4. package/changelog/3.6.x/3.6.0.md +31 -0
  5. package/dist/mcp-server/tools/definitions/_shared/schemas.d.ts.map +1 -1
  6. package/dist/mcp-server/tools/definitions/_shared/schemas.js +1 -1
  7. package/dist/mcp-server/tools/definitions/_shared/schemas.js.map +1 -1
  8. package/dist/mcp-server/tools/definitions/index.d.ts +36 -0
  9. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +12 -0
  11. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +15 -2
  13. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +3 -2
  15. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +4 -3
  17. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +15 -2
  19. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +18 -4
  21. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +12 -0
  23. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +23 -10
  25. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  26. package/dist/services/obsidian/frontmatter-ops.d.ts +4 -3
  27. package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -1
  28. package/dist/services/obsidian/frontmatter-ops.js +379 -114
  29. package/dist/services/obsidian/frontmatter-ops.js.map +1 -1
  30. package/dist/services/obsidian/markdown-blocks.d.ts +39 -0
  31. package/dist/services/obsidian/markdown-blocks.d.ts.map +1 -0
  32. package/dist/services/obsidian/markdown-blocks.js +611 -0
  33. package/dist/services/obsidian/markdown-blocks.js.map +1 -0
  34. package/dist/services/obsidian/obsidian-service.d.ts +9 -7
  35. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  36. package/dist/services/obsidian/obsidian-service.js +308 -111
  37. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  38. package/dist/services/obsidian/patch-instruction.d.ts +141 -0
  39. package/dist/services/obsidian/patch-instruction.d.ts.map +1 -0
  40. package/dist/services/obsidian/patch-instruction.js +217 -0
  41. package/dist/services/obsidian/patch-instruction.js.map +1 -0
  42. package/dist/services/obsidian/section-extractor.d.ts +97 -24
  43. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  44. package/dist/services/obsidian/section-extractor.js +295 -58
  45. package/dist/services/obsidian/section-extractor.js.map +1 -1
  46. package/dist/services/obsidian/types.d.ts +12 -5
  47. package/dist/services/obsidian/types.d.ts.map +1 -1
  48. package/manifest.json +1 -1
  49. package/package.json +2 -1
  50. package/server.json +3 -3
@@ -5,6 +5,7 @@
5
5
  * @module services/obsidian/frontmatter-ops
6
6
  */
7
7
  import { isMap, isScalar, isSeq, parseDocument, Scalar } from 'yaml';
8
+ import { HTML_TAG_SOURCE, scanBlocks } from './markdown-blocks.js';
8
9
  /**
9
10
  * The one frontmatter boundary: an opening `---` alone on the first line, YAML,
10
11
  * then a `---` that starts its own line. The YAML span and the newline that
@@ -309,8 +310,47 @@ function normalizeTagList(value) {
309
310
  }
310
311
  return [];
311
312
  }
312
- const FENCED_CODE_BLOCK = /```[\s\S]*?```|~~~[\s\S]*?~~~/;
313
- const INLINE_CODE = /`[^`\n]+`/;
313
+ /**
314
+ * The opening backtick run of a code span. It closes at the next whole
315
+ * backtick run of the same length in the paragraph, across line breaks:
316
+ * `` a ``b ` #x`` `` is code, `` a `` #x ``` `` is not. Only the opener is a
317
+ * regex; `codeSpanCloser` finds the closer.
318
+ */
319
+ const INLINE_CODE = /(?<code>`+)/;
320
+ /**
321
+ * The end of the code span each opener in `text` starts, or `undefined` when
322
+ * it has none. Whole backtick runs are listed once, by length; openers must be
323
+ * looked up in ascending order, so each length's list is walked once.
324
+ */
325
+ function codeSpanCloser(text) {
326
+ const runs = new Map();
327
+ for (let i = 0; i < text.length;) {
328
+ if (text[i] !== '`') {
329
+ i++;
330
+ continue;
331
+ }
332
+ const start = i;
333
+ while (text[i] === '`')
334
+ i++;
335
+ const list = runs.get(i - start);
336
+ if (list)
337
+ list.push(start);
338
+ else
339
+ runs.set(i - start, [start]);
340
+ }
341
+ const walked = new Map();
342
+ return (open, length) => {
343
+ const list = runs.get(length);
344
+ if (!list)
345
+ return;
346
+ let k = walked.get(length) ?? 0;
347
+ while ((list[k] ?? Number.POSITIVE_INFINITY) <= open)
348
+ k++;
349
+ walked.set(length, k);
350
+ const close = list[k];
351
+ return close === undefined ? undefined : close + length;
352
+ };
353
+ }
314
354
  /**
315
355
  * `[[Target#Heading|Alias]]`. The `#` in a wikilink opens a heading anchor and
316
356
  * the text after `|` is display text — neither is a tag. Protecting the whole
@@ -321,46 +361,30 @@ const INLINE_CODE = /`[^`\n]+`/;
321
361
  * link target, so a span never nests.
322
362
  */
323
363
  const WIKILINK = /\[\[[^[\]\n]*\]\]/;
324
- /** `[text](destination)` — a `#` in the link text or in a URL fragment is link syntax. */
325
- const MARKDOWN_LINK = /\[[^[\]\n]*\]\([^()\n]*\)/;
364
+ /** `![alt](source)` and `![alt][label]` — Obsidian reads no tag in an image's alt text. */
365
+ const IMAGE = /!\[[^[\]\n]*\](?:\([^()\n]*\)|\[[^[\]\n]*\])/;
326
366
  /**
327
- * `[text][label]` and `[text][]` — the reference-style forms of the same link.
328
- * The label must follow the text immediately; a bracketed phrase on its own
329
- * (`[note #work]`) is not a link and its `#` stays a tag.
367
+ * The `](destination)` or `][label]` that makes the bracketed text before it a
368
+ * link. Only this tail is link syntax: Obsidian reads a tag in link text
369
+ * (`[Discord #tf](https://x.y)` is tagged `tf`) and none in a destination, a
370
+ * title, or a label. The text must open on the same line with no bracket in it;
371
+ * a bracketed phrase followed by a space and a second one (`[note #work] [ref]`)
372
+ * is no link.
330
373
  */
331
- const REFERENCE_LINK = /\[[^[\]\n]+\]\[[^[\]\n]*\]/;
374
+ const LINK_TAIL = /(?<linkTail>\](?<=\[[^[\]\n]*\])(?:\([^()\n]*\)|\[[^[\]\n]*\]))/;
375
+ /** The `[label]:` that opens a link reference definition. */
376
+ const LINK_DEFINITION = /\[(?<=(?:^|\n)[ ]{0,3}\[)[^[\]\n]+\]:/;
332
377
  /**
333
378
  * Obsidian's metadata cache reads no tag inside an HTML comment or math. The
334
379
  * rules below are pinned against its readback (Obsidian 1.13.7) rather than a
335
- * spec, and a few differ from CommonMark: an inline span ends at an empty or
336
- * spaces-only line but runs through a tab-only one. Issue #138.
380
+ * spec. HTML blocks and display math blocks are block structure and live in
381
+ * `markdown-blocks.ts`; these are the spans inside one paragraph. Issue #138.
337
382
  *
338
- * An **HTML block** opens with `<!--` at the start of a line — after up to
339
- * three spaces of indentation, or after list-item and blockquote markers — and
340
- * runs through the end of the line holding the first `-->`, or to the end of
341
- * the note when none follows. The whole closing line is hidden, so the tag in
342
- * `<!-- a --> #rd` is not one. A line opening with `<!-->` or `<!--->` closes
343
- * on itself.
344
- *
345
- * The prefix gives every whitespace character exactly one owner — a list
346
- * marker takes the one it requires, the next marker's indent takes the rest,
347
- * and the tail after the last marker takes what follows it — so the lookbehind
348
- * cannot backtrack exponentially over a long run of markers. The `(?=<!--)`
349
- * gate runs it only where a comment opens.
350
- */
351
- const HTML_BLOCK = /(?=<!--)(?<=(?:^|\n)(?:[ \t]*(?:>|(?:[-+*]|\d{1,9}[.)])[ \t]))*(?:(?<=>)[ \t]?[ ]{0,3}|(?<=(?:[-+*]|\d[.)])[ \t])[ \t]*|[ ]{0,3}))<!--(?:-?>[^\n]*|[\s\S]*?-->[^\n]*|[\s\S]*)/;
352
- /**
353
- * An **inline comment** anywhere else: `<!--`, then text that does not open
354
- * with `>` or `->` and holds no `--`, then `-->`, within one paragraph.
355
- * `<!-- x -- y -->` is not a comment, and neither is an unclosed `<!--`.
383
+ * An **inline comment**: `<!--`, then text that does not open with `>` or `->`
384
+ * and holds no `--`, then `-->`. `<!-- x -- y -->` is not a comment, and
385
+ * neither is an unclosed `<!--`.
356
386
  */
357
387
  const HTML_COMMENT = /<!--(?!-?>)(?:(?!--|\n *\r?\n)[\s\S])*-->/;
358
- /**
359
- * A **display math block**: a line that opens with `$$` and holds no second
360
- * `$$`. It runs, across empty lines, to the next line that is `$$` alone, or to
361
- * the end of the note — `$$\na\nb$$` does not close it.
362
- */
363
- const DISPLAY_MATH = /(?<=(?:^|\n)[ \t]*)\$\$(?![^\n]*\$\$)[\s\S]*?(?:\n[ \t]*\$\$[ \t]*(?=\r?\n|$)|$)/;
364
388
  /** Unescaped `$$ … $$` within one paragraph, with no spacing rule. */
365
389
  const INLINE_DOUBLE_MATH = /(?<=(?:^|[^\\])(?:\\\\)*)\$\$(?:(?!\n *\r?\n)[\s\S])*?\$\$/;
366
390
  /**
@@ -427,25 +451,48 @@ function closesInlineMath(content, i) {
427
451
  * A **backslash escape** of ASCII punctuation: `\]#t` and `\\#t` are tags,
428
452
  * `\#t` is an escaped hash and not one.
429
453
  */
430
- const ESCAPE = /\\[!-/:-@[-`{-~]/;
431
- /** An **inline HTML tag**: `<br>#t`, `x <b>#t</b>`, `<a href="u">#t</a>`. */
432
- const HTML_TAG = /<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>\n]*)?\/?>/;
454
+ const ESCAPE = /(?<escape>\\[!-/:-@[-`{-~])/;
455
+ /**
456
+ * An **inline HTML tag**: `<br>#t`, `x <b>#t</b>`, `<a href="u">#t</a>`. Only
457
+ * a tag CommonMark's grammar accepts is one; `x <a b #t> y` is text.
458
+ */
459
+ const HTML_TAG = new RegExp(`(?:${HTML_TAG_SOURCE})`);
433
460
  /**
434
461
  * An **emphasis, highlight, or strikethrough delimiter run** directly before
435
462
  * `#` that has a partner elsewhere on its line: `**#t**`, `*#t*`, `**x**#t`,
436
463
  * `foo*#t*bar`, `==#t==`, `~~#t~~`. An unpartnered run is literal text to
437
464
  * Obsidian (`a *#t`, `a ==#t`), and so is a single `~`. The partner test is
438
- * the same delimiter anywhere else on the line. `_` runs are left out: `_` is a
439
- * tag character, so `_#t_` would read as the tag `t_`, where Obsidian reads `t`.
465
+ * the same delimiter anywhere else on the line. `_` pairs by rules of its own,
466
+ * in `underscoreMarks`.
440
467
  */
441
468
  const EMPHASIS_RUN = /(?<!\*)(?=\*+#)(?:(?<=\*[^\n]*?)|(?=\*+#[^\n]*?\*))\*+|(?<!=)(?===#)(?:(?<===[^\n]*?)|(?===#[^\n]*?==))==|(?<!~)(?=~~#)(?:(?<=~~[^\n]*?)|(?=~~#[^\n]*?~~))~~/;
442
469
  /**
443
470
  * A **blockquote marker** directly before `#`: `>#t`, `> >#t`. A table cell
444
- * pipe is the same kind of node (`|#t|` in a table row is a tag), but telling
445
- * a table row from a pipe in prose takes the lines above it, which this
446
- * one-pass scan does not look at, so `|#t` stays unread either way.
471
+ * pipe is the same kind of node; `markupMarks` protects those, since telling a
472
+ * table row from a pipe in prose takes the block structure around it.
447
473
  */
448
474
  const QUOTE_MARKER = />(?=#)(?<=(?:^|\n)[ \t]*(?:>[ \t]*)*>)/;
475
+ /**
476
+ * Every span a tag cannot live in, or that a `#` right after opens a tag, in
477
+ * the order `protectedSpans` tries them: the construct that opens first wins,
478
+ * so `$b <!-- c$` is math and `<!-- $b -->` is a comment.
479
+ */
480
+ const INLINE_SPANS = new RegExp([
481
+ INLINE_CODE,
482
+ WIKILINK,
483
+ IMAGE,
484
+ LINK_TAIL,
485
+ LINK_DEFINITION,
486
+ HTML_COMMENT,
487
+ INLINE_DOUBLE_MATH,
488
+ INLINE_MATH_OPEN,
489
+ ESCAPE,
490
+ HTML_TAG,
491
+ EMPHASIS_RUN,
492
+ QUOTE_MARKER,
493
+ ]
494
+ .map((r) => r.source)
495
+ .join('|'), 'g');
449
496
  /**
450
497
  * The characters that end an inline tag, as the body of a character class:
451
498
  * whitespace, ASCII punctuation other than `_`, `-`, and `/`, and the General
@@ -466,11 +513,32 @@ const TAG_CHAR = `[^${TAG_STOP}]`;
466
513
  * blocks a tag in Obsidian — `(#a`, `.#b`, `x—#c`, an unpartnered `a *#d` —
467
514
  * and so does the `\` of an escaped `\#`. A segment starts at the note body or
468
515
  * right after a protected span, and Obsidian reads a tag glued to either:
469
- * `[[x]]#t`, `` `c`#t ``, `**#t**`, and `<br>#t` are tags.
516
+ * `[[x]]#t`, `` `c`#t ``, `**#t**`, `<br>#t`, `[#t]`, `_#t_`, and a table
517
+ * cell's `|#t|` are tags.
470
518
  */
471
519
  const TAG_LEFT_BOUNDARY = String.raw `(^|\s)`;
472
520
  /** Obsidian rejects a tag made only of ASCII digits (`#1984`); `#1990s` and `#١٢٣` are tags. */
473
521
  const ALL_DIGITS = /^[0-9]+$/;
522
+ /**
523
+ * A run of tags glued end to end, `#a#b`. Obsidian reads each `#` that
524
+ * directly follows a tag as the start of another one — `#tl#tm` is two tags,
525
+ * `#tn/#to` is `tn/` and `to` — but not one after an all-digit run, since that
526
+ * run was never a tag (`#1984#x` holds none).
527
+ */
528
+ const TAG_CHAIN_SOURCE = `${TAG_LEFT_BOUNDARY}((?:#${TAG_CHAR}+)+)`;
529
+ const TAG_CHAIN = new RegExp(TAG_CHAIN_SOURCE, 'gu');
530
+ /** A chain plus the single horizontal space after it, which a removal may take. */
531
+ const TAG_CHAIN_SPACED = new RegExp(`${TAG_CHAIN_SOURCE}([ \\t]?)`, 'gu');
532
+ /** The tags in a matched chain: each name, up to the first all-digit one. */
533
+ function chainTags(chain) {
534
+ const tags = [];
535
+ for (const name of chain.slice(1).split('#')) {
536
+ if (ALL_DIGITS.test(name))
537
+ break;
538
+ tags.push(name);
539
+ }
540
+ return tags;
541
+ }
474
542
  /**
475
543
  * Inline `#tag` syntax lives in the body. The frontmatter block is spliced off
476
544
  * first and re-attached verbatim, so a `#` inside a YAML scalar is neither read
@@ -481,10 +549,9 @@ function mutateInlineTags(content, tags, operation, applied, skipped) {
481
549
  const segments = splitProtectedSegments(body);
482
550
  let updatedNonCode = false;
483
551
  if (operation === 'add') {
552
+ const present = new Set(inlineTags(segments));
484
553
  for (const tag of tags) {
485
- const re = makeInlineTagRegex(tag);
486
- const present = segments.some((s) => !s.protected && re.test(s.text));
487
- if (present) {
554
+ if (present.has(tag)) {
488
555
  skipped.add(tag);
489
556
  }
490
557
  else {
@@ -522,7 +589,7 @@ function mutateInlineTags(content, tags, operation, applied, skipped) {
522
589
  skipped.add(tag);
523
590
  continue;
524
591
  }
525
- const re = makeInlineTagRegex(tag);
592
+ const remove = removeFromChain(tag);
526
593
  let found = false;
527
594
  for (const s of segments) {
528
595
  if (s.protected)
@@ -534,7 +601,7 @@ function mutateInlineTags(content, tags, operation, applied, skipped) {
534
601
  * space. Repeat until the segment stops changing — every pass drops at
535
602
  * least the tag itself, so this terminates.
536
603
  */
537
- for (let next = s.text.replace(re, removeAt); next !== s.text; next = s.text.replace(re, removeAt)) {
604
+ for (let next = s.text.replace(TAG_CHAIN_SPACED, remove); next !== s.text; next = s.text.replace(TAG_CHAIN_SPACED, remove)) {
538
605
  s.text = next;
539
606
  found = true;
540
607
  }
@@ -570,42 +637,96 @@ function removeAt(full, leading, trailing, offset, whole) {
570
637
  return trailing;
571
638
  }
572
639
  /**
573
- * Split the body into stretches a tag may live in and stretches it may not:
574
- * code, where a `#` is code; link syntax, where a `#` is a heading anchor or
575
- * link text; HTML comments and math, where Obsidian reads no tag; and the
576
- * markup after which a `#` opens a tag (`ESCAPE` through `QUOTE_MARKER`). The
577
- * construct that opens first wins, so `$b <!-- c$` is math and `<!-- $b -->`
578
- * is a comment. Both the read and the write path run over the result, so
640
+ * A `TAG_CHAIN` replacer that drops every tag named `tag` from a chain and
641
+ * keeps the rest. Chains are split at `#` rather than matched against the
642
+ * name, so removing `caf` cannot strip the front off `#café`. A chain that
643
+ * loses all its tags takes one adjacent space with it, per `removeAt`.
644
+ */
645
+ function removeFromChain(tag) {
646
+ return (full, leading, chain, trailing, offset, whole) => {
647
+ const names = chain.slice(1).split('#');
648
+ const tagged = chainTags(chain).length;
649
+ const kept = names.filter((name, i) => i >= tagged || name !== tag);
650
+ if (kept.length === names.length)
651
+ return full;
652
+ if (kept.length === 0)
653
+ return removeAt(full, leading, trailing, offset, whole);
654
+ return `${leading}#${kept.join('#')}${trailing}`;
655
+ };
656
+ }
657
+ /** Every inline tag in the segments a tag may live in, each once, in order of first appearance. */
658
+ function inlineTags(segments) {
659
+ const tags = new Set();
660
+ for (const seg of segments) {
661
+ if (seg.protected)
662
+ continue;
663
+ for (const m of seg.text.matchAll(TAG_CHAIN)) {
664
+ for (const tag of chainTags(m[2] ?? ''))
665
+ tags.add(tag);
666
+ }
667
+ }
668
+ return [...tags];
669
+ }
670
+ /**
671
+ * Split the body into stretches a tag may live in and stretches it may not.
672
+ * `scanBlocks` finds the block structure — code, HTML blocks, and display math
673
+ * are hidden whole — and each paragraph, heading, and table row is split by
674
+ * `splitInline`. Both the read and the write path run over the result, so
579
675
  * `list` and `remove` agree on what counts as a tag.
580
676
  */
581
677
  function splitProtectedSegments(content) {
582
678
  const segments = [];
583
679
  let cursor = 0;
584
- const re = new RegExp([
585
- FENCED_CODE_BLOCK,
586
- INLINE_CODE,
587
- WIKILINK,
588
- MARKDOWN_LINK,
589
- REFERENCE_LINK,
590
- HTML_BLOCK,
591
- HTML_COMMENT,
592
- DISPLAY_MATH,
593
- INLINE_DOUBLE_MATH,
594
- INLINE_MATH_OPEN,
595
- ESCAPE,
596
- HTML_TAG,
597
- EMPHASIS_RUN,
598
- QUOTE_MARKER,
599
- ]
600
- .map((r) => r.source)
601
- .join('|'), 'g');
602
- const mathCloser = inlineMathCloser(content);
680
+ const push = (isProtected, text) => {
681
+ if (text.length > 0)
682
+ segments.push({ protected: isProtected, text });
683
+ };
684
+ for (const block of scanBlocks(content)) {
685
+ push(false, content.slice(cursor, block.start));
686
+ const text = content.slice(block.start, block.end);
687
+ if (block.kind === 'hidden')
688
+ push(true, text);
689
+ else
690
+ for (const seg of splitInline(text, block.tableRow))
691
+ push(seg.protected, seg.text);
692
+ cursor = block.end;
693
+ }
694
+ push(false, content.slice(cursor));
695
+ return segments;
696
+ }
697
+ /**
698
+ * The spans of `text` a tag cannot live in, or that a `#` right after opens a
699
+ * tag, in order.
700
+ */
701
+ function protectedSpans(text) {
702
+ const spans = [];
703
+ const mathCloser = inlineMathCloser(text);
704
+ const codeCloser = codeSpanCloser(text);
705
+ INLINE_SPANS.lastIndex = 0;
603
706
  for (;;) {
604
- const m = re.exec(content);
707
+ const m = INLINE_SPANS.exec(text);
605
708
  if (!m)
606
709
  break;
607
- let matched = m[0] ?? '';
608
- if (m.groups?.inlineMath !== undefined) {
710
+ let start = m.index;
711
+ let end = start + m[0].length;
712
+ if (m.groups?.code !== undefined) {
713
+ /**
714
+ * A run with no closer leaves its first backtick as text, and the rest of
715
+ * the run is tried as an opener of its own — where CommonMark would make
716
+ * the whole run text, Obsidian reads `` a ``b #x` `` as the code span
717
+ * `` `b #x` ``. Each shorter opener is tried here rather than by
718
+ * re-running the regex, which would rescan the run from every backtick.
719
+ */
720
+ const runEnd = end;
721
+ let close = codeCloser(start, runEnd - start);
722
+ while (close === undefined && ++start < runEnd)
723
+ close = codeCloser(start, runEnd - start);
724
+ if (close === undefined)
725
+ continue;
726
+ end = close;
727
+ INLINE_SPANS.lastIndex = end;
728
+ }
729
+ else if (m.groups?.inlineMath !== undefined) {
609
730
  /**
610
731
  * The `$$` constructs are tried before this one and nothing after it
611
732
  * opens with `$`, so an opener with no closer leaves this position to
@@ -614,57 +735,201 @@ function splitProtectedSegments(content) {
614
735
  const close = mathCloser(m.index);
615
736
  if (close === undefined)
616
737
  continue;
617
- matched = content.slice(m.index, close + 1);
618
- re.lastIndex = close + 1;
619
- }
620
- if (m.index > cursor) {
621
- segments.push({ protected: false, text: content.slice(cursor, m.index) });
738
+ end = close + 1;
739
+ INLINE_SPANS.lastIndex = end;
622
740
  }
623
- segments.push({ protected: true, text: matched });
624
- cursor = m.index + matched.length;
741
+ const kind = m.groups?.escape !== undefined
742
+ ? 'escape'
743
+ : m.groups?.linkTail !== undefined
744
+ ? 'linkTail'
745
+ : 'other';
746
+ spans.push({ start, end, kind });
625
747
  }
626
- if (cursor < content.length) {
627
- segments.push({ protected: false, text: content.slice(cursor) });
748
+ return spans;
749
+ }
750
+ /**
751
+ * Split one paragraph, heading, or table row. Beyond the spans of
752
+ * `protectedSpans`, three kinds of markup become protected one-character (or
753
+ * one-run) segments, since a `#` directly after each opens a tag: the brackets
754
+ * of a bracketed span, a table row's cell pipes, and an underscore run that
755
+ * pairs as emphasis (`underscoreMarks`).
756
+ */
757
+ function splitInline(text, tableRow) {
758
+ const spans = protectedSpans(text);
759
+ /** `markEnd[i]` is the end of the mark starting at `i`, or 0. */
760
+ const markEnd = new Uint32Array(text.length);
761
+ const linkTexts = markupMarks(text, spans, tableRow, markEnd);
762
+ underscoreMarks(text, spans, linkTexts, markEnd);
763
+ const segments = [];
764
+ let cursor = 0;
765
+ const cut = (start, end) => {
766
+ if (start > cursor)
767
+ segments.push({ protected: false, text: text.slice(cursor, start) });
768
+ segments.push({ protected: true, text: text.slice(start, end) });
769
+ cursor = end;
770
+ };
771
+ let s = 0;
772
+ for (let i = 0; i < text.length;) {
773
+ const span = spans[s];
774
+ if (span?.start === i) {
775
+ cut(i, span.end);
776
+ i = span.end;
777
+ s++;
778
+ }
779
+ else if (markEnd[i]) {
780
+ const end = markEnd[i];
781
+ cut(i, end);
782
+ i = end;
783
+ }
784
+ else
785
+ i++;
628
786
  }
787
+ if (cursor < text.length)
788
+ segments.push({ protected: false, text: text.slice(cursor) });
629
789
  return segments;
630
790
  }
631
791
  /**
632
- * Captures the character before the tag and the single horizontal space after
633
- * it, if any. The lookahead refuses a longer tag that merely starts with `tag`
634
- * — removing `caf` must not strip the front off `#café`.
792
+ * Mark the brackets of every bracketed span and, in a table row, every cell
793
+ * pipe; return the link texts — the ranges between a `[` and the `LINK_TAIL`
794
+ * that closes it. Pinned against Obsidian readback: `[#t]`, `x [a]#t`, and
795
+ * `[#t](u)` are tags, as is `[#t` closed on the next line of the paragraph,
796
+ * while `x [#t` (never closed), `x []#t` (empty), `[^#t]` (a footnote), and
797
+ * the outer `[` of `[#t [b] c]` (a `[` before the `]`) open none. A `]]`
798
+ * closes a wikilink, not a bracketed span.
799
+ */
800
+ function markupMarks(text, spans, tableRow, markEnd) {
801
+ const linkTexts = [];
802
+ let open = -1;
803
+ let s = 0;
804
+ for (let i = 0; i < text.length;) {
805
+ const span = spans[s];
806
+ if (span?.start === i) {
807
+ if (span.kind === 'linkTail') {
808
+ if (open >= 0 && i > open + 1) {
809
+ markEnd[open] = open + 1;
810
+ linkTexts.push([open + 1, i]);
811
+ }
812
+ open = -1;
813
+ }
814
+ i = span.end;
815
+ s++;
816
+ continue;
817
+ }
818
+ const ch = text[i];
819
+ if (ch === '[') {
820
+ open = text[i + 1] === '^' ? -1 : i;
821
+ }
822
+ else if (ch === ']') {
823
+ if (open >= 0 && i > open + 1 && text[i + 1] !== ']') {
824
+ markEnd[open] = open + 1;
825
+ markEnd[i] = i + 1;
826
+ }
827
+ open = -1;
828
+ }
829
+ else if (ch === '|' && tableRow) {
830
+ markEnd[i] = i + 1;
831
+ }
832
+ i++;
833
+ }
834
+ return linkTexts;
835
+ }
836
+ const TAG_AT = new RegExp(`#${TAG_CHAR}+`, 'uy');
837
+ /**
838
+ * Mark the underscore runs that pair as emphasis. `_` is a tag character, so
839
+ * this decides both where a tag may start (`x _#t_ y` is tagged `t`,
840
+ * `snake_case_#t` is tagged `t`) and where one ends (the closing `_` of
841
+ * `_#t_` is not part of it). None of this is CommonMark's flanking rule; it is
842
+ * fitted to more than eighty Obsidian 1.13.7 readback probes. Walking the
843
+ * paragraph's runs in order, with at most one opener pending:
844
+ *
845
+ * - With none pending, a run becomes the opener.
846
+ * - A run followed by an ASCII letter or digit cannot close (`x _#t_b` holds
847
+ * no tag) and is passed over.
848
+ * - A run longer than the opener cannot close it and is passed over.
849
+ * - When the text between opener and run both starts and ends with whitespace
850
+ * (`a_ _#t`), the pair fails and the run becomes the opener instead.
851
+ * - Otherwise the two pair, and both are marks.
852
+ *
853
+ * A run inside a tag's name, or inside a protected span — code, math, a
854
+ * comment, a link destination — can close but never open: `#qb_name_ x` is the
855
+ * tag `qb_name_`, and `x `a_` b_#t` holds no tag while `x _a `_` b_#t` pairs
856
+ * the first two runs. An escaped `\_` is text. Link text pairs on its own, as
857
+ * CommonMark nests it: `[x _a_#t](u)` is tagged `t`, while the run in
858
+ * `[a_](u) b_#t` cannot reach past the link.
635
859
  */
636
- function makeInlineTagRegex(tag) {
637
- const escaped = tag.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
638
- return new RegExp(`${TAG_LEFT_BOUNDARY}#${escaped}(?!${TAG_CHAR})([ \\t]?)`, 'gu');
860
+ function underscoreMarks(text, spans, linkTexts, markEnd) {
861
+ let outer;
862
+ let inner;
863
+ /** Index of the link text the walk is in, or -1 outside every link. */
864
+ let link = -1;
865
+ let tagEnd = -1;
866
+ let s = 0;
867
+ let l = 0;
868
+ /** Offer `run` to the pending opener; returns the opener pending afterwards. */
869
+ const offer = (opener, run, closeOnly) => {
870
+ if (!opener)
871
+ return closeOnly ? undefined : run;
872
+ if (/[A-Za-z0-9]/.test(text[run.end] ?? ''))
873
+ return opener;
874
+ if (run.end - run.start > opener.end - opener.start)
875
+ return opener;
876
+ if (/\s/.test(text[opener.end] ?? '') && /\s/.test(text[run.start - 1] ?? '')) {
877
+ return closeOnly ? undefined : run;
878
+ }
879
+ markEnd[opener.start] = opener.end;
880
+ markEnd[run.start] = run.end;
881
+ return;
882
+ };
883
+ for (let i = 0; i < text.length;) {
884
+ while ((spans[s]?.end ?? Number.POSITIVE_INFINITY) <= i)
885
+ s++;
886
+ const span = spans[s];
887
+ const inSpan = span !== undefined && span.start <= i;
888
+ if (inSpan && span.kind === 'escape') {
889
+ i = span.end;
890
+ continue;
891
+ }
892
+ const ch = text[i];
893
+ if (ch === '#' && !inSpan) {
894
+ TAG_AT.lastIndex = i;
895
+ if (TAG_AT.test(text))
896
+ tagEnd = TAG_AT.lastIndex;
897
+ }
898
+ if (ch !== '_') {
899
+ i++;
900
+ continue;
901
+ }
902
+ const run = { start: i, end: i };
903
+ while (text[run.end] === '_' && (!inSpan || run.end < span.end))
904
+ run.end++;
905
+ i = run.end;
906
+ const closeOnly = inSpan || run.start < tagEnd;
907
+ while ((linkTexts[l]?.[1] ?? Number.POSITIVE_INFINITY) <= run.start)
908
+ l++;
909
+ if ((linkTexts[l]?.[0] ?? Number.POSITIVE_INFINITY) <= run.start) {
910
+ if (link !== l)
911
+ inner = undefined;
912
+ link = l;
913
+ inner = offer(inner, run, closeOnly);
914
+ }
915
+ else {
916
+ outer = offer(outer, run, closeOnly);
917
+ }
918
+ }
639
919
  }
640
920
  /**
641
921
  * Read-only helpers for `obsidian_manage_tags list`. Inline tags are read from
642
922
  * the body only, and through the same protected-segment split and left boundary
643
923
  * a removal uses, so what `list` reports is exactly what `remove` can reach — a
644
- * `#` inside a YAML scalar, a code span, a link span, an HTML comment, or math,
645
- * or one glued to the character before it (`\#`, `(#x`), is none of them. A tag
646
- * runs for as long as `TAG_CHAR` matches, and an all-digit run is not a tag.
924
+ * `#` inside a YAML scalar, code, an HTML block or comment, math, an image, or
925
+ * a link's destination or label, or one glued to the character before it
926
+ * (`\#`, `(#x`), is none of them. A tag runs for as long as `TAG_CHAR`
927
+ * matches, and an all-digit run is not a tag.
647
928
  */
648
929
  export function listTagsFromContent(content, frontmatter) {
649
- const fmTags = normalizeTagList(frontmatter.tags);
650
- const inline = [];
651
- const seen = new Set();
652
- /** Each segment is scanned to exhaustion, which resets `lastIndex` between them. */
653
- const re = new RegExp(`${TAG_LEFT_BOUNDARY}#(${TAG_CHAR}+)`, 'gu');
654
- for (const seg of splitProtectedSegments(splice(content).body)) {
655
- if (seg.protected)
656
- continue;
657
- for (;;) {
658
- const m = re.exec(seg.text);
659
- if (!m)
660
- break;
661
- const t = m[2];
662
- if (t && !ALL_DIGITS.test(t) && !seen.has(t)) {
663
- seen.add(t);
664
- inline.push(t);
665
- }
666
- }
667
- }
668
- return { frontmatter: fmTags, inline };
930
+ return {
931
+ frontmatter: normalizeTagList(frontmatter.tags),
932
+ inline: inlineTags(splitProtectedSegments(splice(content).body)),
933
+ };
669
934
  }
670
935
  //# sourceMappingURL=frontmatter-ops.js.map