Skip to content

feat: add opt-in native text and inline code link recognition - #855

Merged
hryhoriiK97 merged 10 commits into
software-mansion:mainfrom
pingdotgg:feat/native-link-recognition
Oct 11, 2026
Merged

hryhoriiK97 merged 10 commits into
software-mansion:mainfrom
pingdotgg:feat/native-link-recognition

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

What/Why?

Implements the API proposed in #850. Consumers can turn application-defined references such as #1234, @name or src/App.tsx into native links without rewriting the Markdown or changing Copy as Markdown output.

Adds an optional linkRecognition prop to EnrichedMarkdownText on iOS, Android and web:

<EnrichedMarkdownText
  markdown="Fixed in #1284, see `src/App.tsx`"
  linkRecognition={{ text: /#\d+/, inlineCode: /[\w./-]+\.tsx?/ }}
  onLinkPress={({ url }) => openReference(url)}
/>
  • text turns every nonempty, non-overlapping match inside a plain-text run into a link.
  • inlineCode turns an inline-code span into a link when its whole content matches. The span keeps its code font unless a link or variant font is set.
  • The matched text is the URL, so linkVariants, linkPillContent, linkContextMenuItems, onLinkPress and onLinkLongPress apply as to any other link.
  • Existing links, autolinks, code blocks, images, video and math are never entered.
  • Both keys are optional and off by default. Only the regex source and the i and s flags are transported; an invalid pattern turns that recognizer off.
  • LinkRecognition is exported. The wrapper keeps the native prop object stable when an inline literal is equal by value.

The name linkRegex was not reused because the input already uses it with the opposite default (URL detection, on by default).

How it works

  • Recognition runs once, in the shared C++ core (packages/core/cpp/parser/TextLinkRecognizer), on the AST right after md4c and before the platform conversion. It collects eligible text runs and inline-code spans in one walk, asks the host for matches in one call per kind, maps the UTF-16 ranges back to bytes, splits the runs into Link nodes marked recognizedLink, and rebuilds only the containers where a child changed.
  • Hosts supply matching only: NSRegularExpression on iOS, java.util.regex through a JNI callback on Android, and a TypeScript port of the recognizer on web. All three report UTF-16 offsets, which is what the core expects.
  • Patterns are compiled once per config: a bounded LRU on Android, an NSCache on iOS. updateProps compares raw props before building configs, so streaming updates do not recompile.
  • LinkRegexConfig moved to shared utils on both platforms; the renderer no longer imports the input package.
  • Serializers and the clipboard extractor skip recognized links on both platforms, so full-document and partial-selection copies retain the original text and backticks.

Android measurement fix

Fabric measurement on Android only receives the props listed in android/src/main/jni/.../conversions.h. Without linkRecognition there, the shadow tree measured plain text while the view rendered links, and once enough matches pushed text onto extra lines the last block was clipped. linkRecognition is now serialized for both the Text and container props, and both measurement cache keys include it.

Docs

linkRecognition section in both API references, including the per-text-run limit and the latexMath $ caveat.

Testing

  • yarn typecheck, yarn lint, yarn test: jest transport tests for the prop normalization and a suite for the web recognizer.
  • CI: library, Android and iOS builds, Android and iOS unit-test lanes, Kotlin and Swift lint, clang-format, docs build and docs/examples parity. All green on the current head.
  • Maestro text_link_recognition_test with committed iOS (iPhone 17, iOS 26.5) and Android (Pixel 9, API 36) screenshot baselines. One Playground document covers a plain-text match, a whole inline-code match, an existing Markdown link, a partial code span, a fenced block and a trailing line, captured with recognition on and again after turning it off on unchanged Markdown. The trailing line is what catches a measure/render split.
  • Manual runs on an iPhone 17 simulator and an API 36 emulator with pills, GitHub and CommonMark flavors, and streaming.

Earlier revisions of this PR shipped standalone Kotlin runners and an XCTest file that nothing compiled. They were removed; native unit tests for the RN package are being adopted in a separate PR.

PR Checklist

  • Code compiles and runs on iOS
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing
  • Required E2E tests have been added if applicable

Adapted from prototype d057e005debe84f633bdafa33c8ab3124bdcd327.
Preserve disabled defaults, code formatting and source/copy behavior
through existing native link renderers.
Merge upstream changes while retaining per-URL link font families
and recognized inline-code fonts. Add focused span regression tests.
juliusmarminge and others added 4 commits October 8, 2026 10:43
Keep both the playground reference-link toggle and the input line-height toggle.
Replace linkRegex / inlineCodeLinkRegex with linkRecognition={{ text, inlineCode }}
on EnrichedMarkdownText, implemented on web as well.

Fix Android measurement: the regex props were never serialized in conversions.h,
so the shadow tree measured without recognition while the view rendered pills,
clipping the last block once enough matches changed line wrapping.

Compile patterns once per config on both platforms (bounded caches), compare raw
props before rebuilding configs in updateProps, and copy AST subtrees only where a
match changed them.

Move LinkRegexConfig out of the input modules, limit the underline side-attribute
to recognized links, and neutralize the invalid-pattern warning.

Drop the standalone Kotlin test harness, its Android shims and JNI-only runners;
native unit tests for the RN package are tracked as a separate adoption PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@hryhoriiK97

Copy link
Copy Markdown
Collaborator

Pushed a follow-up on top of the merge with main (7f812b1). Summary of what changed and why.

API

The two props are now one object, on native and on web:

<EnrichedMarkdownText
  linkRecognition={{ text: /#\d+|@\w+/, inlineCode: /[\w./-]+\.tsx?/ }}
/>

LinkRecognition is exported. Either key may be omitted. The wrapper keeps the native object stable when an inline literal is equal by value, so passing it inline does not re-send the prop. linkRegex was dropped as a name because the input already uses it with the opposite default (URL detection, on by default).

Bug fix: Android measurement ignored recognition

Android Fabric measurement only receives the props hand-listed in android/src/main/jni/.../conversions.h, and the regex props were not there. The shadow tree measured plain text while the view rendered pills, so once enough matches pushed text onto extra lines the last block was clipped. Reproduced with a long document and several pills; the same document written with explicit Markdown links laid out identically and was not clipped. Fixed by serializing linkRecognition for both the Text and container props.

Performance

  • Patterns are compiled once per config: a bounded LRU on Android (LinkRegexConfig.compiled / compiledWholeSpan), an NSCache of ENRMLinkRegexConfig on iOS. Previously every parse and every measure compiled again, and iOS updateProps compiled on every prop update including every streaming chunk.
  • updateProps compares the raw props before building configs.
  • The recognizers copy a container node only when one of its children changed, on all three platforms, so an active recognizer on a document with few matches allocates almost nothing.

Smaller changes

  • LinkRegexConfig moved out of the input modules (utils/common on Android, ios/utils on iOS); the renderer no longer imports the input package.
  • The underline renderer stamps the recognition side-attribute only on recognized links.
  • Invalid-pattern warning no longer claims a fallback to the input's default.
  • Docs: linkRecognition section in both API references, with the per-text-run limit and the latexMath $ caveat.
  • Web implements the prop with a TypeScript port of the recognizer (jest-covered), instead of silently dropping it.

Tests

  • Kept: jest transport tests and a new jest suite for the web recognizer.
  • Removed: scripts/test-text-link-recognizer.sh, the jvm-shims tree, the main()-style Kotlin runners and the XCTest file. The Kotlin runners were never executed by Gradle and the XCTest file sat in __tests__/native, which nothing compiles. The RN package has no native unit tests on main today, so adopting them is being handled as a separate PR, seeded with these suites.
  • The Maestro flow is unchanged for now.

Verification

typecheck, lint, 90 jest tests, Android compile + ktlint + unit lane, iOS build + test lane, clang-format, docs parity checks, and manual runs on an iPhone 17 simulator and an API 36 emulator with pills, GitHub and CommonMark flavors, and streaming.

Move the recognizer out of Kotlin and Objective-C into packages/core/cpp/parser,
where it runs on the C++ AST right after md4c, before either platform converts it.
Hosts supply only matching: both bridges hand every text run and inline-code span
over in one call per kind and get back UTF-16 ranges from their own regex engine;
the core maps those to bytes, splits the runs and rebuilds only changed containers.

iOS builds the matchers on NSRegularExpression in MarkdownParserBridge. Android calls
back into TextLinkMatching through JNI with UTF-16 strings, clears any host
exception before touching JNI again, and adds keep rules for the two classes the
bridge reaches by name. The per-platform walks and the misnamed iOS recognizer
files are removed; the config cache is a class method on ENRMLinkRegexConfig.

Web keeps its TypeScript pass for now. Core C++ tests arrive with a harness in a
separate PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@hryhoriiK97

Copy link
Copy Markdown
Collaborator

Second follow-up pushed (0c62fde): recognition now runs once, in the shared C++ core, instead of once per platform.

What moved

  • New packages/core/cpp/parser/TextLinkRecognizer.{hpp,cpp}. It runs on the C++ AST right after md4c, before iOS or Android convert it: collects every eligible text run and inline-code span in one walk, asks the host for matches in one call per kind, maps the returned ranges to bytes, splits the runs into Link nodes marked recognizedLink, and rebuilds only the containers where a child changed. Opaque nodes (links, code blocks, media, math) and the whole-span rule for inline code are defined here and nowhere else.
  • Hosts supply only matching. The contract is small: text runs in, UTF-16 ranges out, which is what NSRegularExpression, java.util.regex and JS all report natively.
  • iOS: MarkdownParserBridge.mm builds the two matchers on NSRegularExpression and calls the core before converting to Objective-C nodes. The Objective-C AST walk is gone; the config cache is now +[ENRMLinkRegexConfig cachedConfigWithPattern:caseInsensitive:dotAll:] and the prop templates live in ios/utils/ENRMLinkRegexProps.h.
  • Android: nativeParseMarkdown takes the two configs. The JNI bridge calls back into a new TextLinkMatching object once per kind, passing UTF-16 strings via NewString so offsets line up, and clears any host exception before touching JNI again. TextLinkRecognizer.kt is deleted. Keep rules were added for TextLinkMatching and LinkRegexConfig.

Why

Both platforms' render and measurement paths go through these bridges, so they can no longer disagree on recognition rules; that was the class of the measurement bug fixed in the previous push. It also puts the pass where the planned custom inline elements (#246, #54) would attach: another collector and another host callback, not another per-platform walk.

Behavior and performance

No user-visible change. The regex work is identical and runs in the same engine as before; the move adds one UTF-16 conversion per text run on both platforms, which is linear and small next to matching. The pattern caches, raw-prop comparison and copy-on-change from the previous push carry over.

Not in this push

  • Web keeps its TypeScript pass. The wasm build lists its sources explicitly and has no callback path into JS yet.
  • C++ tests for the core. There is no harness in packages/core today; one is coming in a separate PR for the whole core, and the recognizer test will land with it.

Verification

jest and typecheck; Android compile, ktlint, app build and a device sweep (Everything/Off presets, pills on and off, GitHub and CommonMark, control document, no JNI warnings); iOS build and test lane, app running on a simulator; the standalone enriched-markdown-android parser module, which also compiles cpp/parser/*, builds; clang-format clean on every touched native file.

Replace the tap-and-alert flow with the screenshot pattern the other inline_elements
flows use. One document covers a plain-text match, a whole inline-code match, an
existing link, a partial code span, a fence and a trailing line; captured with
recognition on (GitHub) and off again on unchanged Markdown. A clipped trailing
block, which is what a measure/render split on Android looks like, now fails the
flow. Baselines generated on iPhone 17 / iOS 26.5 and Pixel9-API36.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@hryhoriiK97

Copy link
Copy Markdown
Collaborator

Third follow-up: the Maestro flow now follows the screenshot pattern used by the other inline_elements flows instead of tapping links and asserting alerts.

One Playground document covers a plain-text match, a whole inline-code match, an existing Markdown link, a partial code span (stays code), a fenced block (stays plain) and a trailing line. It is captured with recognition on (GitHub flavor) and again after turning recognition off on unchanged Markdown. Baselines for iOS (iPhone 17, iOS 26.5) and Android (Pixel9-API36) are committed and re-assert at the 100% threshold.

This is the check that would have caught the Android measurement bug fixed earlier in this PR: a measure/render split clips the trailing line, which fails the screenshot, while tap-based assertions still pass.

A link recognized from a whole inline-code span kept the code font even
when the link style or a variant set one, unlike an explicit link wrapping
code. The configured font now wins on iOS and Android; with no font
configured the span still keeps its code font.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both API references listed it right after markdown. It now follows the
props type order, after linkPillContent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@hryhoriiK97
hryhoriiK97 merged commit 7eb36e8 into software-mansion:main Oct 11, 2026
19 checks passed
eszlamczyk added a commit that referenced this pull request Oct 16, 2026
#855 added it under packages/core/cpp/parser unformatted; the core-parser
CI job runs yarn lint-clang:core-parser over that directory.
eszlamczyk added a commit that referenced this pull request Oct 16, 2026
* fix: isolate the embedded parser behind an ENRMRKD_ prefix

Enriched embedded MD4C under its original global names, so an application
that also links another MD4C copy failed with a duplicate definition of
md_parse, and the CocoaPods header map could resolve the bare md4c.h
include in the Objective-C++ input parser to another pod's copy (#846).

Vendor the parser from software-mansion-labs/md4c rather than mity/md4c:
the fork renames both the filenames and everything they expose to an
ENRMRKD_/enrmrkd_ prefix, so two copies can coexist. Rename the consumers
to match, and follow the vendored directory rename through the podspecs,
the SwiftPM manifest, the Android CMake files and the example app project.

Includes stay plain and keep resolving through each target's header search
paths; what removes the header map ambiguity is the filename being specific
to the fork, since no other pod ships an enrmrkd.h.

The public API is unchanged: md4cFlags, Md4cFlags, MD4CParser and md4cType
keep their names.

Re-vendoring also moves the parser forward to the fork's master, which
carries upstream work postdating the Sep 17 snapshot (HTML block start
condition 4, the pipe handling rework, the video tag revert). Serialized
ASTs are identical for every markdown file in the repo across five flag
variants, and differ on 49 of 1000 generated adversarial documents - for
instance an empty "||||" spoiler now staying literal text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(core): guard the vendored parser with golden ASTs in CI

The parser is synced from software-mansion-labs/md4c, so a sync can change
how documents parse without any native build noticing. That is not
hypothetical: the migration in the previous commit silently dropped video
rendering, because the fork had inherited upstream's revert of <video> as a
block-level HTML tag (mity/md4c#428) and MD4CParser only promotes <video>
into a video node when the parser reports an HTML block.

Add scripts/test-core-parser.sh, run by a core-parser CI job gated on
packages/core/cpp changes. It compiles the vendored parser and MD4CParser
with the host toolchain, then checks that the parser exports nothing but
enrmrkd_* symbols, that the serialized AST of five fixture documents under
five flag variants matches a golden dump, and that the parser still links
and runs beside upstream mity/md4c (pinned tag) in one binary. The golden
dump is re-recorded with --update, so an intended parser change arrives as a
reviewable diff.

Vendor the fork's restored <video> block tag
(software-mansion-labs/md4c#2), which the golden fixtures cover; running the
check against the previously vendored parser fails on html-blocks.md in all
five variants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address cr, update ci and rebuild wasm

* fix: replace all path-only includes with search-path ones

* feat: resolve final issues

* docs: web renders videos now that the WASM bundle is rebuilt

The rebuilt md4c.js emits the `Video` node that #765 added, and the web
renderer has had a `VideoRenderer`, `videoStyle` and the 'Video' node type
all along, so the tag now renders as the browser's own `<video>` player.
Six doc spots said the opposite, all of them written against the old
artifact.

Also drops the `unavailable` banner from the video LivePreview, so the
style-properties example is live like its neighbours, and rewrites the
build.sh note the stale artifact had earned - scripts/test-web-bundle.mjs
now guards the bundle itself, so the note only has to warn about prose.

Created with usage of AI tools

* style(core): clang-format TextLinkRecognizer for the core-parser lint

#855 added it under packages/core/cpp/parser unformatted; the core-parser
CI job runs yarn lint-clang:core-parser over that directory.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants