Skip to content

fix: require word boundaries for mrkdwn emphasis markers - #96

Open
mliem2k wants to merge 1 commit into
shaharia-lab:mainfrom
mliem2k:fix/mrkdwn-word-boundary-underscore
Open

fix: require word boundaries for mrkdwn emphasis markers#96
mliem2k wants to merge 1 commit into
shaharia-lab:mainfrom
mliem2k:fix/mrkdwn-word-boundary-underscore

Conversation

@mliem2k

@mliem2k mliem2k commented Aug 5, 2026

Copy link
Copy Markdown

Fixes #103

Summary

parseInline paired a marker with the next occurrence of the same character anywhere later in the text, with no check on what surrounded either marker. An underscore inside one identifier or URL (the merge_requests path segment in a GitLab link) paired with an unrelated underscore much later in the message, italicising everything between them and deleting both underscores, which destroyed both URLs.

An underscore now only opens a span when it is not preceded by a word character, and only closes one when it is not followed by one. The rule is scoped to underscores because Slack applies *bold*, ~strike~ and `code` mid-word. When the closing underscore sits mid-word the span stays literal rather than searching on for a later candidate, which would swallow the text in between.

The word-character test runs on whole code points and counts combining marks, so a decomposed é (e + U+0301) and astral letters are not mistaken for non-word characters.

Behaviour change

One case differs from main beyond the bug itself: _hello_world_ is now fully literal, where it previously rendered italic hello followed by plain world_. That matches Slack leaving snake_case unformatted, and literal is the safe failure mode for a parser whose output cannot be read back before it is sent.

*bold*, ~strike~ and `code` are unchanged in every case, including mid-word (*a*b, a`b`c), doubled markers (**bold**, ~~strike~~) and a ~ inside a URL path followed by a later ~strike~ span.

Test plan

  • bun test (323 pass, 0 fail) and bun run type-check clean.
  • New cases cover: two underscore-bearing URLs no longer pair, a single identifier stays literal, _italic_ still works alongside a later file_name, an underscore pair spanning an identifier stays literal, both Unicode forms of café_, an astral letter before _, mid-word bold/strike/code, doubled markers, and a URL ~ against a later strike span.

Copilot AI lite review requested due to automatic review settings August 5, 2026 05:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the mrkdwn inline parser (parseInline) to enforce Slack-like word-boundary rules for emphasis markers, preventing underscores inside URLs/identifiers from pairing with unrelated underscores later in a message and corrupting rendered output.

Changes:

  • Add word-boundary checks so a marker cannot open if preceded by a word character, and cannot close if followed by a word character.
  • Update closing-marker search to skip mid-word candidates instead of stopping at the first match.
  • Add regression tests to ensure underscores in URLs/identifiers are not misinterpreted as emphasis while _italic_ still parses.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/lib/mrkdwn.ts Adds word-boundary logic for opening/closing emphasis spans and improves closing-marker search behavior.
src/lib/mrkdwn.test.ts Adds regression tests for underscore parsing in URLs/identifiers and mixed cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/mrkdwn.ts Outdated
Comment on lines +35 to +37
function isWordChar(ch: string | undefined): boolean {
return ch !== undefined && /[A-Za-z0-9]/.test(ch);
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a54e3d9: switched to /[\p{L}\p{N}]/u so any Unicode letter or number counts as a word character, not just ASCII.

Comment thread src/lib/mrkdwn.test.ts
Comment on lines +159 to +163
it('does not open emphasis on a marker directly after a word character', () => {
expect(elements('snake_case_name stays literal')).toEqual([
{ type: 'text', text: 'snake_case_name stays literal' },
]);
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in a54e3d9: a regression test with cafe_italic_ that fails against the old ASCII-only check and passes with the Unicode-aware one.

@vibexp-gh vibexp-gh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this. The underscore-in-URL bug is real and worth fixing, but applying the word-boundary rule uniformly to all four markers introduces regressions. Requesting changes.

Confirmed regressions (traced old vs new output):

  • **bold** -> old ['*', bold('bold'), '*'], new [bold('*bold'), '*']. The opener skips the adjacent * and pairs with the far one, so a literal * lands inside the bold span.
  • ~~strike~~ -> old ['~', strike('strike'), '~'], new [strike('~strike'), '~']. Same mechanism.
  • see https://host/~user and ~important~ -> old strikes only important, new strikes user and ~important. This is the same swallow bug the PR set out to fix, just moved from _ to ~.
  • *a*b, *bold*2, a`b`c -> now render fully literal. Per Slack's own docs *bold* and backtick code spans work mid-word; only _italic_ requires boundaries. The uniform rule drops valid Slack formatting.

Lower severity:

  • isWordChar indexes by UTF-16 code unit. NFD café_italic_ italicizes while NFC stays literal (the added test only covers NFC, masking this); astral letters are misclassified via lone surrogates. Use codePointAt or normalize to NFC.

Suggested direction: scope the word-boundary rule to _ only (matching Slack), and separately fix the "skip to a later same-marker candidate" logic so it does not merge unrelated spans.

Process: per the repo guidelines, every PR must link an existing issue that carries the ready-for-pr label. This PR has no linked issue and no label, so it cannot be merged as-is regardless of the code.

parseInline paired a marker with the next occurrence of the same
character anywhere later in the text, with no check on what came before
or after either marker. An underscore inside one identifier or URL (the
merge_requests path segment in a GitLab link, say) paired with an
unrelated underscore much later in the message, italicising everything
between them and deleting both underscores, which destroyed both URLs.

An underscore now only opens a span when it is not preceded by a word
character, and only closes one when it is not followed by one. The rule
is scoped to underscores because Slack applies *bold*, ~strike~ and
`code` mid-word. When the closing underscore sits mid-word the span
stays literal rather than searching on for a later candidate, which
would swallow the text in between.

The word-character test runs on whole code points and counts combining
marks, so a decomposed "e" plus combining acute and astral letters are
not mistaken for non-word characters.
@mliem2k
mliem2k force-pushed the fix/mrkdwn-word-boundary-underscore branch from a54e3d9 to c861a2e Compare August 6, 2026 14:10
@mliem2k

mliem2k commented Aug 6, 2026

Copy link
Copy Markdown
Author

All six confirmed by tracing old vs new output, and all six are now fixed. Pushed as c861a2e, rebuilt as a single commit off main at v0.8.0.

The word-boundary rule is scoped to _ only, matching Slack. **bold**, ~~strike~~, *a*b, *bold*2, a`b`c and see https://host/~user and ~important~ now produce output identical to main, byte for byte.

On the skip-to-a-later-candidate logic: removed rather than repaired. When the closing _ sits mid-word the span stays literal, so there is no path left where one span reaches past an unrelated marker to close.

The word-character test now runs on whole code points via codePointAt and includes \p{M}, so the NFD case you named (cafe + U+0301) and astral letters no longer open a span. Both are covered: the test loops over 'caf\u00e9' and 'cafe\u0301' written as escapes rather than raw characters, so the two forms cannot be confused on screen, and there is a separate U+1D400 case.

One deliberate difference from main worth flagging, since it is not one of your findings: _hello_world_ is now fully literal, where it previously italicised hello and left world_. That matches Slack leaving snake_case unformatted, and literal is the safer failure mode here given a draft cannot be read back before it is sent. Happy to revisit if you would rather keep the old split.

On process, you are right that there was no linked issue. Opened #103 with the WHAT/WHY/HOW and linked it from the PR description. It has not been triaged yet, so this is not mergeable until a maintainer adds ready-for-pr.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: messages draft destroys URLs containing an underscore (unrelated underscores pair into one italic span)

3 participants