Skip to content

fix: parse link and image destinations and titles - #39

Open
RobinDev wants to merge 2 commits into
tempestphp:mainfrom
RobinDev:fix/link-destination-and-title
Open

RobinDev wants to merge 2 commits into
tempestphp:mainfrom
RobinDev:fix/link-destination-and-title

Conversation

@RobinDev

Copy link
Copy Markdown
Contributor

Closes part of #37 (point 2). Follows up on this comment confirming the behaviour is specified in CommonMark 0.31.2, §6.3 Links and §6.4 Images.

The (…) part of an inline link or image was consumed as a single opaque string, so a title ended up inside href/src, an angle-bracket destination kept its brackets, a destination containing a space still produced a link, and entities were escaped twice.

What changes

A new InlineDestination::scan() reads (destination "title") from the raw content and returns null when it is malformed. Both LinkRule and ImageRule use it:

  • the title is parsed separately, in all three forms ("…", '…', (…)), and rendered as a title attribute on LinkToken / ImageToken;
  • <…> destinations are unwrapped, which is how a destination containing spaces is written;
  • entity references in the destination and the title are decoded once, so they are escaped exactly once on output;
  • a malformed destination no longer produces a link — the label stays literal text, which is what CommonMark specifies for [x](a b).

Balanced parentheses and backslash escapes keep working as they did since #25.

Input Before After
[x](/a "Title") <a href="/a &quot;Title&quot;">x</a> <a href="/a" title="Title">x</a>
[x](a b) <a href="a b">x</a> [x](a b)
[x](</my uri>) <a href="&lt;/my uri&gt;">x</a> <a href="/my uri">x</a>
![alt](/a "Title") <img src="/a &quot;Title&quot;" alt="alt"> <img src="/a" alt="alt" title="Title">
[x](/a&amp;b) <a href="/a&amp;amp;b">x</a> <a href="/a&amp;b">x</a>

ImageSourceWasMissing and ImageSourceWasNotClosed still fire for ![alt] and for ![alt](foo world with no closing parenthesis on the line; only a well-formed-but-invalid destination degrades to text.

LinkToken and ImageToken gain an optional title — appended after the existing arguments, so no call site breaks.

Deliberately out of scope

URL normalisation. CommonMark percent-encodes unsafe characters in the destination, so [x](</my uri>) is href="/my%20uri" there and href="/my uri" here. That touches every destination with a non-ASCII or reserved character, so it deserves its own PR rather than riding along with the parsing fix. Happy to do it next if you want it.

Escaping in the surrounding text. [x](/a "unclosed) degrades to literal text correctly, but the " is not escaped to &quot; — that is point 3 of #37 and needs the text-token work, not this.

composer test (339 tests), lint and analyse are clean.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

Benchmark Results

Comparison of fix/link-destination-and-title against main (5e06bea775768b2af19579ec592d17d625bac4ef).

Open to see the benchmark results

No benchmark changes above ±5%.

Generated by phpbench against commit 1a25389

@RobinDev

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit for the benchmark regression the bot flagged (+10.68% small, +7.62% large).

The scanner walked the destination one character at a time; it now bulk-skips with strcspn to the next character that actually needs attention, the same way Parser::consumeUntilUnescaped() did before. A destination with no escape and no parenthesis — the common case — is now one strcspn plus one substr.

Measured locally over three alternating runs against main (--php-config='{"opcache.enable_cli": 1}'):

Set main before after
01-small 0.195ms 0.224ms 0.199ms
02-large 11.84ms 13.30ms 11.87ms

The large set is back at parity. The small one keeps about +2%, which is the fixed cost of parsing a title and an angle-bracket destination on a 7KB fixture with ten links.

Behaviour is unchanged: same 339 tests, and the same output on every CommonMark comparison case in the description.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant