Repository navigation
feat: add opt-in native text and inline code link recognition - #855
Conversation
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.
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>
|
Pushed a follow-up on top of the merge with APIThe two props are now one object, on native and on web: <EnrichedMarkdownText
linkRecognition={{ text: /#\d+|@\w+/, inlineCode: /[\w./-]+\.tsx?/ }}
/>
Bug fix: Android measurement ignored recognitionAndroid Fabric measurement only receives the props hand-listed in Performance
Smaller changes
Tests
Verificationtypecheck, 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>
|
Second follow-up pushed (0c62fde): recognition now runs once, in the shared C++ core, instead of once per platform. What moved
WhyBoth 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 performanceNo 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
Verificationjest 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 |
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>
|
Third follow-up: the Maestro flow now follows the screenshot pattern used by the other 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>
#855 added it under packages/core/cpp/parser unformatted; the core-parser CI job runs yarn lint-clang:core-parser over that directory.
* 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>
What/Why?
Implements the API proposed in #850. Consumers can turn application-defined references such as
#1234,@nameorsrc/App.tsxinto native links without rewriting the Markdown or changing Copy as Markdown output.Adds an optional
linkRecognitionprop toEnrichedMarkdownTexton iOS, Android and web:textturns every nonempty, non-overlapping match inside a plain-text run into a link.inlineCodeturns 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.linkVariants,linkPillContent,linkContextMenuItems,onLinkPressandonLinkLongPressapply as to any other link.iandsflags are transported; an invalid pattern turns that recognizer off.LinkRecognitionis exported. The wrapper keeps the native prop object stable when an inline literal is equal by value.The name
linkRegexwas not reused because the input already uses it with the opposite default (URL detection, on by default).How it works
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 markedrecognizedLink, and rebuilds only the containers where a child changed.NSRegularExpressionon iOS,java.util.regexthrough 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.NSCacheon iOS.updatePropscompares raw props before building configs, so streaming updates do not recompile.LinkRegexConfigmoved to shared utils on both platforms; the renderer no longer imports the input package.Android measurement fix
Fabric measurement on Android only receives the props listed in
android/src/main/jni/.../conversions.h. WithoutlinkRecognitionthere, 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.linkRecognitionis now serialized for both the Text and container props, and both measurement cache keys include it.Docs
linkRecognitionsection in both API references, including the per-text-run limit and thelatexMath$caveat.Testing
yarn typecheck,yarn lint,yarn test: jest transport tests for the prop normalization and a suite for the web recognizer.text_link_recognition_testwith 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.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