Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
appId: swmansion.enriched.markdown.example
tags:
- advanced
- inline
- link
- text
---
# linkRecognition turns plain-text and whole-inline-code matches into links, leaves
# existing links, fences and partial code matches alone, and keeps the trailing block
# visible (measurement must see the same links the view renders).
- launchApp

- runFlow:
file: '../../../../subflows/move_to_playground.yaml'

- runFlow:
file: '../../../subflows/set_enriched_text_value.yaml'
env:
VALUE: |
See ref:one, `ref:two`, [ref:three](https://existing.invalid) and `prefix ref:four`.

```
ref:five stays plain in a fence
```

Last line: ref:six.

- scrollUntilVisible:
element:
id: recognize-links-button
direction: DOWN

# Off -> CommonMark -> GitHub
- tapOn:
id: recognize-links-button
- tapOn:
id: recognize-links-button

- scrollUntilVisible:
element:
id: preview-container
direction: DOWN

- runFlow:
file: '../../../subflows/capture_or_assert_screenshot.yaml'
env:
SCREENSHOT_NAME: 'text_link_recognition_display'

# Recognition off again on unchanged Markdown: generated links disappear.
- scrollUntilVisible:
element:
id: recognize-links-button
direction: UP
- tapOn:
id: recognize-links-button

- scrollUntilVisible:
element:
id: preview-container
direction: DOWN

- runFlow:
file: '../../../subflows/capture_or_assert_screenshot.yaml'
env:
SCREENSHOT_NAME: 'text_link_recognition_off_display'
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const INLINE_IMAGE_URI = Image.resolveAssetSource(
require('../../assets/logo_icon.png')
).uri;

const REFERENCE_REGEX = /ref:[a-z]+/;
const DEFAULT_INPUT_LINE_HEIGHT = undefined;
const TALL_INPUT_LINE_HEIGHT = 36;

Expand All @@ -73,6 +74,7 @@ export default function PlaygroundScreen() {
const [underlineEnabled, setUnderlineEnabled] = useState(true);
const [setMarkdownModalVisible, setSetMarkdownModalVisible] = useState(false);
const [rawInput, setRawInput] = useState('');
const [recognitionMode, setRecognitionMode] = useState(0);
const [inputLineHeight, setInputLineHeight] = useState<number | undefined>(
DEFAULT_INPUT_LINE_HEIGHT
);
Expand Down Expand Up @@ -268,12 +270,26 @@ export default function PlaygroundScreen() {
<View style={styles.divider} />

<Text style={styles.previewLabel}>Preview</Text>
<TouchableOpacity
style={styles.button}
onPress={() => setRecognitionMode((mode) => (mode + 1) % 3)}
testID="recognize-links-button"
>
<Text style={styles.buttonText}>
Reference links: {['Off', 'CommonMark', 'GitHub'][recognitionMode]}
</Text>
</TouchableOpacity>
<View style={styles.previewContainer} testID="preview-container">
{markdown.length > 0 ? (
<EnrichedMarkdownText
markdown={markdown}
markdownStyle={MARKDOWN_STYLE}
flavor="github"
flavor={recognitionMode === 1 ? 'commonmark' : 'github'}
linkRecognition={
recognitionMode
? { text: REFERENCE_REGEX, inlineCode: REFERENCE_REGEX }
: undefined
}
spoilerOverlay="solid"
md4cFlags={{
underline: underlineEnabled,
Expand Down
24 changes: 24 additions & 0 deletions docs-md/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,30 @@ This is content, not style: keep `markdownStyle` stable and change this map as l
/>
```

### `linkRecognition`

Opt-in link recognition for text that is not written as a Markdown link, such as `#1234`, `@name`, `$skill` or a file path. Off by default. Works in both flavors on every platform.

| Key | Type | Matches |
| --- | ---- | ------- |
| `text` | `RegExp` | Every nonempty, non-overlapping match inside a plain-text run |
| `inlineCode` | `RegExp` | An inline-code span whose whole content matches becomes one link |

A recognized link's URL is the matched text itself, so `linkVariants`, `linkPillContent`, `linkContextMenuItems`, `onLinkPress` and `onLinkLongPress` apply to it like to any other link. Existing links, autolinks, code blocks, images, video and math are never touched. The Markdown source is unchanged: copying, including Copy as Markdown, returns the original text without link syntax.

Matching happens per parsed text run, after Markdown parsing, so a pattern cannot match across formatting. `__init__.py` as plain text is split by emphasis parsing and will not match, while `` `__init__.py` `` as inline code matches whole. With the `latexMath` flag on (the default), two `$` on one line form a math span, so `$deploy ... $test` is math, not two skills; turn the flag off or escape the `$` in such content.

Only the regex source and the `i` and `s` flags are used. Patterns must be valid for `NSRegularExpression` and `java.util.regex.Pattern` as well as JavaScript; an invalid pattern turns that recognizer off. This is different from the input's `linkRegex`, which detects URLs and is on by default. Pass the object inline or hoisted as you like: an equal config does not re-send the prop.

```tsx
<EnrichedMarkdownText
markdown="Fixed in #1284, see `src/App.tsx`"
linkRecognition={{ text: /#\d+/, inlineCode: /[\w./-]+\.tsx?/ }}
markdownStyle={{ linkVariants: { '^#\\d+$': { pill: true } } }}
onLinkPress={({ url }) => openReference(url)}
/>
```

### `contextMenuItems`

Custom items to add to the text selection context menu. Items appear before the system actions (Copy, etc.). Items with `visible: false` are hidden from the menu.
Expand Down
24 changes: 24 additions & 0 deletions docs/docs/react-native/api-reference/enriched-markdown-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,30 @@ interface LinkPillContent {

<LivePreview src={LinkPillContentSrc} unavailable unavailableReason={<>iOS and Android only - pills are drawn by the native text stack.</>} />

### `linkRecognition`

Opt-in link recognition for text that is not written as a Markdown link, such as `#1234`, `@name`, `$skill` or a file path. Off by default. Works in both flavors on every platform.

| Key | Type | Matches |
| --- | ---- | ------- |
| `text` | `RegExp` | Every nonempty, non-overlapping match inside a plain-text run |
| `inlineCode` | `RegExp` | An inline-code span whose whole content matches becomes one link |

A recognized link's URL is the matched text itself, so `linkVariants`, `linkPillContent`, `linkContextMenuItems`, `onLinkPress` and `onLinkLongPress` apply to it like to any other link. Existing links, autolinks, code blocks, images, video and math are never touched. The Markdown source is unchanged: copying, including Copy as Markdown, returns the original text without link syntax.

Matching happens per parsed text run, after Markdown parsing, so a pattern cannot match across formatting. `__init__.py` as plain text is split by emphasis parsing and will not match, while `` `__init__.py` `` as inline code matches whole. With the `latexMath` flag on (the default), two `$` on one line form a math span, so `$deploy ... $test` is math, not two skills; turn the flag off or escape the `$` in such content.

Only the regex source and the `i` and `s` flags are used. Patterns must be valid for `NSRegularExpression` and `java.util.regex.Pattern` as well as JavaScript; an invalid pattern turns that recognizer off. This is different from the input's `linkRegex`, which detects URLs and is on by default. Pass the object inline or hoisted as you like: an equal config does not re-send the prop.

```tsx
<EnrichedMarkdownText
markdown="Fixed in #1284, see `src/App.tsx`"
linkRecognition={{ text: /#\d+/, inlineCode: /[\w./-]+\.tsx?/ }}
markdownStyle={{ linkVariants: { '^#\\d+$': { pill: true } } }}
onLinkPress={({ url }) => openReference(url)}
/>
```

### `contextMenuItems`

Custom items to add to the text selection context menu. Items appear before the system actions and are hidden when `visible: false`. On iOS this requires iOS 16+; on earlier versions the prop is ignored.
Expand Down
175 changes: 175 additions & 0 deletions packages/core/cpp/parser/TextLinkRecognizer.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#include "TextLinkRecognizer.hpp"

#include <memory>
#include <string>
#include <unordered_map>
#include <utility>

namespace Markdown {

namespace {

using NodePtr = std::shared_ptr<MarkdownASTNode>;
using NodeList = std::vector<NodePtr>;

bool isOpaque(NodeType type) {
switch (type) {
case NodeType::Link:
case NodeType::CodeBlock:
case NodeType::Image:
case NodeType::Video:
case NodeType::LatexMathInline:
case NodeType::LatexMathDisplay:
return true;
default:
return false;
}
}

NodePtr makeLink(NodePtr child, const std::string& url) {
auto link = std::make_shared<MarkdownASTNode>(NodeType::Link);
link->setAttribute("url", url);
link->setAttribute("recognizedLink", "true");
link->addChild(std::move(child));
return link;
}

NodePtr textSlice(const MarkdownASTNode& source, std::string content) {
auto text = std::make_shared<MarkdownASTNode>(NodeType::Text);
text->content = std::move(content);
text->attributes = source.attributes;
return text;
}

// Byte offset of each UTF-16 code unit boundary in a UTF-8 string, plus the end.
std::vector<size_t> utf16BoundariesToBytes(const std::string& text) {
std::vector<size_t> bytes;
bytes.reserve(text.size() + 1);
size_t i = 0;
while (i < text.size()) {
unsigned char lead = static_cast<unsigned char>(text[i]);
size_t length = lead < 0x80 ? 1 : lead < 0xE0 ? 2 : lead < 0xF0 ? 3 : 4;
if (i + length > text.size()) length = text.size() - i;
bytes.push_back(i);
if (length == 4) bytes.push_back(i); // surrogate pair: two units, one start
i += length;
}
bytes.push_back(text.size());
return bytes;
}

struct Candidate {
MarkdownASTNode* parent;
size_t index;
};

void collect(MarkdownASTNode& node, bool wantText, bool wantCode, std::vector<Candidate>& texts,
std::vector<Candidate>& codes) {
for (size_t i = 0; i < node.children.size(); i++) {
MarkdownASTNode& child = *node.children[i];
if (isOpaque(child.type)) continue;
if (child.type == NodeType::Text) {
if (wantText && !child.content.empty()) texts.push_back({&node, i});
} else if (child.type == NodeType::Code) {
if (wantCode) codes.push_back({&node, i});
} else {
collect(child, wantText, wantCode, texts, codes);
}
}
}

std::string codeContent(const MarkdownASTNode& code) {
std::string content;
for (const auto& child : code.children) content += child->content;
return content;
}

using Replacements = std::unordered_map<const MarkdownASTNode*, NodeList>;

void splitText(const NodePtr& textNode, const std::vector<TextRange>& ranges, Replacements& out) {
const std::string& content = textNode->content;
std::vector<size_t> bytes = utf16BoundariesToBytes(content);
NodeList pieces;
size_t offset = 0;
for (const TextRange& range : ranges) {
if (range.end <= range.start || range.end >= bytes.size()) continue;
size_t start = bytes[range.start];
size_t end = bytes[range.end];
if (start < offset || end <= start) continue;
if (start > offset) pieces.push_back(textSlice(*textNode, content.substr(offset, start - offset)));
std::string matched = content.substr(start, end - start);
pieces.push_back(makeLink(textSlice(*textNode, matched), matched));
offset = end;
}
if (offset == 0) return;
if (offset < content.size()) pieces.push_back(textSlice(*textNode, content.substr(offset)));
out[textNode.get()] = std::move(pieces);
}

void applyReplacements(MarkdownASTNode& node, const Replacements& replacements) {
bool changed = false;
for (const auto& child : node.children) {
if (replacements.count(child.get())) {
changed = true;
break;
}
}
if (changed) {
NodeList children;
children.reserve(node.children.size());
for (const auto& child : node.children) {
auto it = replacements.find(child.get());
if (it == replacements.end()) {
children.push_back(child);
} else {
children.insert(children.end(), it->second.begin(), it->second.end());
}
}
node.children = std::move(children);
}
for (const auto& child : node.children) {
if (!isOpaque(child->type)) applyReplacements(*child, replacements);
}
}

} // namespace

void recognizeTextLinks(MarkdownASTNode& root, const TextLinkMatchers& matchers) {
const bool wantText = static_cast<bool>(matchers.text);
const bool wantCode = static_cast<bool>(matchers.inlineCode);
if (!wantText && !wantCode) return;

std::vector<Candidate> texts;
std::vector<Candidate> codes;
collect(root, wantText, wantCode, texts, codes);
if (texts.empty() && codes.empty()) return;

Replacements replacements;

if (!texts.empty()) {
std::vector<std::string_view> runs;
runs.reserve(texts.size());
for (const Candidate& c : texts) runs.emplace_back(c.parent->children[c.index]->content);
std::vector<std::vector<TextRange>> matches = matchers.text(runs);
for (size_t i = 0; i < texts.size() && i < matches.size(); i++) {
if (!matches[i].empty()) splitText(texts[i].parent->children[texts[i].index], matches[i], replacements);
}
}

if (!codes.empty()) {
std::vector<std::string> contents;
contents.reserve(codes.size());
for (const Candidate& c : codes) contents.push_back(codeContent(*c.parent->children[c.index]));
std::vector<std::string_view> spans(contents.begin(), contents.end());
std::vector<uint8_t> matched = matchers.inlineCode(spans);
for (size_t i = 0; i < codes.size() && i < matched.size(); i++) {
if (!matched[i] || contents[i].empty()) continue;
const NodePtr& code = codes[i].parent->children[codes[i].index];
replacements[code.get()] = NodeList{makeLink(code, contents[i])};
}
}

if (!replacements.empty()) applyReplacements(root, replacements);
}

} // namespace Markdown
28 changes: 28 additions & 0 deletions packages/core/cpp/parser/TextLinkRecognizer.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#pragma once

#include "MarkdownASTNode.hpp"
#include <cstddef>
#include <functional>
#include <cstdint>
#include <string_view>
#include <vector>

namespace Markdown {

// Offsets are UTF-16 code units, as every host regex engine reports them.
struct TextRange {
size_t start;
size_t end;
};

// Host-provided matching, one call per kind per parse. A null function is off.
struct TextLinkMatchers {
// Non-overlapping matches per run, in order.
std::function<std::vector<std::vector<TextRange>>(const std::vector<std::string_view>& runs)> text;
std::function<std::vector<uint8_t>(const std::vector<std::string_view>& spans)> inlineCode;
};

// In place. Existing links, code blocks, media and math are not entered.
void recognizeTextLinks(MarkdownASTNode& root, const TextLinkMatchers& matchers);

} // namespace Markdown
Loading
Loading