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
7 changes: 7 additions & 0 deletions docs/docs/android/api-reference/enriched-markdown-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ data class MarkdownOptions(
val permissiveAutolinks: Boolean = true,
val hardSoftBreaks: Boolean = false,
val preserveBlankLines: Boolean = false,
val preserveWindowsPaths: Boolean = false,
val admonitions: Boolean = false,
) {
companion object {
Expand Down Expand Up @@ -191,6 +192,12 @@ Keeps consecutive blank lines instead of collapsing them, so vertical whitespace

<PropInfo type="Boolean" default="false" />

#### `preserveWindowsPaths`

When `true`, a backslash that separates a Windows drive path stays in the text instead of acting as an escape, so `C:\Users\me\.config` keeps every separator. CommonMark would render it as `C:\Users\me.config`. Escapes outside such a path, code spans, and link destinations are unchanged.

<PropInfo type="Boolean" default="false" />

#### `admonitions`

Renders a blockquote whose first line is `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, or `> [!CAUTION]` as a themed callout with an icon and a title. Without it the marker stays literal text inside an ordinary quote. Colors come from the [`admonitions`](/android/api-reference/style-properties#admonitions) block.
Expand Down
7 changes: 7 additions & 0 deletions docs/docs/ios/api-reference/enriched-markdown-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public struct MarkdownParsingOptions: Sendable, Equatable {
public var hardSoftBreaks: Bool // false
public var permissiveAutolinks: Bool // true
public var preserveBlankLines: Bool // false
public var preserveWindowsPaths: Bool // false
public var admonitions: Bool // false

public static let commonMark: MarkdownParsingOptions
Expand Down Expand Up @@ -127,6 +128,12 @@ Keeps consecutive blank lines instead of collapsing them, so vertical whitespace

<PropInfo type="Bool" default="false" />

#### `preserveWindowsPaths`

When `true`, a backslash that separates a Windows drive path stays in the text instead of acting as an escape, so `C:\Users\me\.config` keeps every separator. CommonMark would render it as `C:\Users\me.config`. Escapes outside such a path, code spans, and link destinations are unchanged.

<PropInfo type="Bool" default="false" />

#### `admonitions`

Renders a blockquote whose first line is `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, or `> [!CAUTION]` as a themed callout with an icon and a title. Without it the marker stays literal text inside an ordinary quote. Colors come from the [`Admonition()`](/ios/api-reference/style-properties#admonition) element.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Markdown flavor. `'commonmark'` (default) renders the whole document as a single

Toggles for md4c's parser extensions; each opts a piece of extra inline syntax in or out. Pass only the flags you want to change; the rest keep their defaults below. Where a flag enables a new inline element, tune its appearance through the matching [style property](/react-native/api-reference/style-properties).

<PropInfo type="Md4cFlags" default="{ underline: false, superscript: false, subscript: false, highlight: false, latexMath: true, hardSoftBreaks: false, preserveBlankLines: false, admonitions: true }" />
<PropInfo type="Md4cFlags" default="{ underline: false, superscript: false, subscript: false, highlight: false, latexMath: true, hardSoftBreaks: false, preserveBlankLines: false, preserveWindowsPaths: false, admonitions: true }" />

#### `underline`

Expand Down Expand Up @@ -189,6 +189,12 @@ When `true`, preserves runs of consecutive blank lines from the source instead o

<LivePreview src={Md4cPreserveBlankLinesSrc} />

#### `preserveWindowsPaths`

When `true`, a backslash that separates a Windows drive path stays in the text instead of acting as an escape, so `C:\Users\me\.config` keeps every separator. CommonMark would render it as `C:\Users\me.config`. Escapes outside such a path, code spans, and link destinations are unchanged.

<PropInfo type="boolean" default="false" />

#### `admonitions`

Renders GitHub-style admonitions (also called alerts): a blockquote whose first line is `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, or `> [!CAUTION]` becomes a themed callout with an icon and a title header instead of a plain quote. Unlike the other flags, this one is **on by default**.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/react-native/guides/web-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ All core `EnrichedMarkdownText` features are supported on web, including:
- [`renderers`](/react-native/guides/custom-block-renderers) - React replacements for standalone images, standalone links and `<video>` blocks, with the same block ids and placements as native; the element replaces the block in the DOM and keeps its spacing
- `resolveImageSource` - swaps the `src` of every image occurrence, synchronously or from a promise; headers are dropped because `<img>` requests cannot carry them, and a pending or failed resolution renders the image without a source
- `enableTaskListItemToggle` - set to `false` to render task list checkboxes read-only (the click is fully inert: no toggle, no `onTaskListItemPress`). The checkbox keeps its normal appearance, marked `readOnly` / `aria-disabled` and made pointer-inert rather than `disabled`, matching iOS and Android
- `allowTrailingMargin`, `containerStyle`, `selectable`, `selectionColor`, `md4cFlags` (`underline`, `superscript`, `subscript`, `latexMath`, `highlight`, `hardSoftBreaks`, `preserveBlankLines`, `admonitions`)
- `allowTrailingMargin`, `containerStyle`, `selectable`, `selectionColor`, `md4cFlags` (`underline`, `superscript`, `subscript`, `latexMath`, `highlight`, `hardSoftBreaks`, `preserveBlankLines`, `preserveWindowsPaths`, `admonitions`)
- GitHub admonitions (`> [!NOTE]`) - rendered as callouts with the same icon set and `blockquote.admonitions` palette as native. There is no `flavor` prop on web, so they are always on unless `md4cFlags={{ admonitions: false }}`
- [RTL and mixed-direction (bidi) content](/user-experience/rtl), resolved per block as on native
- Arbitrary DOM attributes, which pass through to the root `<div>`
Expand Down
2 changes: 2 additions & 0 deletions packages/core/cpp/enrmrkd/enrmrkd.c
Original file line number Diff line number Diff line change
Expand Up @@ -4784,6 +4784,8 @@ md_process_inlines(MD_CTX* ctx, const MD_LINE* lines, ENRMRKD_SIZE n_lines)
case '\\': /* Backslash escape. */
if(ISNEWLINE(mark->beg+1))
enforce_hardbreak = 1;
else if((ctx->parser.flags & ENRMRKD_FLAG_ESCAPEDTEXT) && text_type == ENRMRKD_TEXT_NORMAL)
ENRMRKD_TEXT(ENRMRKD_TEXT_ESCAPED, STR(mark->beg+1), 1);
else
ENRMRKD_TEXT(text_type, STR(mark->beg+1), 1);
break;
Expand Down
8 changes: 7 additions & 1 deletion packages/core/cpp/enrmrkd/enrmrkd.h
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,12 @@ typedef enum ENRMRKD_TEXTTYPE {

/* Text is inside an equation. This is processed the same way as inlined code
* spans (`code`). */
ENRMRKD_TEXT_LATEXMATH
ENRMRKD_TEXT_LATEXMATH,

/* Character escaped by a backslash (e.g. "\\*"), without the backslash.
* Sent only with ENRMRKD_FLAG_ESCAPEDTEXT, and only where the escaped character
* would otherwise be ENRMRKD_TEXT_NORMAL. */
ENRMRKD_TEXT_ESCAPED
} ENRMRKD_TEXTTYPE;


Expand Down Expand Up @@ -406,6 +411,7 @@ typedef struct ENRMRKD_BLOCK_BLANK_DETAIL {
#define ENRMRKD_FLAG_HIGHLIGHT 0x200000 /* Enable ==highlight== spans. */
#define ENRMRKD_FLAG_PRESERVEBLANKLINES 0x400000 /* Report blank line runs as ENRMRKD_BLOCK_BLANK. */
#define ENRMRKD_FLAG_INSERT 0x800000 /* Enable insert extension. */
#define ENRMRKD_FLAG_ESCAPEDTEXT 0x2000000 /* Report backslash-escaped characters as ENRMRKD_TEXT_ESCAPED. */

#define ENRMRKD_FLAG_PERMISSIVEAUTOLINKS (ENRMRKD_FLAG_PERMISSIVEEMAILAUTOLINKS | ENRMRKD_FLAG_PERMISSIVEURLAUTOLINKS | ENRMRKD_FLAG_PERMISSIVEWWWAUTOLINKS)
#define ENRMRKD_FLAG_NOHTML (ENRMRKD_FLAG_NOHTMLBLOCKS | ENRMRKD_FLAG_NOHTMLSPANS)
Expand Down
101 changes: 101 additions & 0 deletions packages/core/cpp/parser/MD4CParser.cpp
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
#include "MD4CParser.hpp"
#include "enrmrkd.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
extern "C" {
#include "enrmrkd-entity.h"
}
#include <cstring>
#include <string_view>
#include <vector>

namespace Markdown {
Expand Down Expand Up @@ -71,6 +73,48 @@ void appendCharacterReference(std::string &output, const char *text, size_t size
appendCodepoint(output, codepoint);
}

bool isPathWhitespace(const std::string &text, size_t at) {
const auto c = static_cast<unsigned char>(text[at]);
if (c < 128)
return c == ' ' || (c >= '\t' && c <= '\r');
if (c != 0xC2 && c != 0xE1 && c != 0xE2 && c != 0xE3 && c != 0xEF)
return false;
// Match JavaScript's \s, including non-ASCII spaces in decoded prose.
static const std::string_view spaces[] = {"\u00a0", "\u1680", "\u2000", "\u2001", "\u2002", "\u2003", "\u2004",
"\u2005", "\u2006", "\u2007", "\u2008", "\u2009", "\u200a", "\u2028",
"\u2029", "\u202f", "\u205f", "\u3000", "\ufeff"};
for (const auto space : spaces)
if (text.compare(at, space.size(), space) == 0)
return true;
return false;
}

bool isDriveLetter(unsigned char c) {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
}

bool isWordCharacter(unsigned char c) {
return isDriveLetter(c) || (c >= '0' && c <= '9') || c == '_';
}

// A drive path can run through formatting, as in `C:/Users/**me**\.config`.
bool carriesWindowsPath(NodeType type) {
switch (type) {
case NodeType::Strong:
case NodeType::Emphasis:
case NodeType::Underline:
case NodeType::Strikethrough:
case NodeType::Spoiler:
case NodeType::Superscript:
case NodeType::Subscript:
case NodeType::Highlight:
case NodeType::Link:
return true;
default:
return false;
}
}

struct HtmlTag {
std::string name;
std::unordered_map<std::string, std::string> attributes;
Expand All @@ -84,6 +128,11 @@ class MD4CParser::Impl {
std::shared_ptr<MarkdownASTNode> root;
std::vector<std::shared_ptr<MarkdownASTNode>> nodeStack;
std::string currentText;
bool trackWindowsPaths = false;
size_t pathScanned = 0;
bool inWindowsPath = false;
// The last three bytes scanned, newest first; 0 before any.
unsigned char pathTail[3] = {0, 0, 0};
const char *inputText = nullptr;
bool inHtmlBlock = false;
std::string htmlBlockContent;
Expand All @@ -108,12 +157,16 @@ class MD4CParser::Impl {
nodeStack.reserve(std::min(estimatedDepth, static_cast<size_t>(128)));
nodeStack.push_back(root);
currentText.clear();
resetWindowsPath();
currentText.reserve(256);
inHtmlBlock = false;
htmlBlockContent.clear();
}

void flushText() {
if (trackWindowsPaths)
scanWindowsPath();
pathScanned = 0;
if (!currentText.empty() && !nodeStack.empty()) {
auto textNode = std::make_shared<MarkdownASTNode>(NodeType::Text);
textNode->content = std::move(currentText);
Expand All @@ -122,8 +175,40 @@ class MD4CParser::Impl {
}
}

void resetWindowsPath() {
pathScanned = 0;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
inWindowsPath = false;
pathTail[0] = pathTail[1] = pathTail[2] = 0;
}

// Inspects only text appended since the last scan, so many escapes cannot rescan a paragraph.
void scanWindowsPath() {
for (; pathScanned < currentText.size(); ++pathScanned) {
const auto c = static_cast<unsigned char>(currentText[pathScanned]);
if (isPathWhitespace(currentText, pathScanned)) {
inWindowsPath = false;
} else if (!inWindowsPath && (c == '/' || c == '\\') && endsInDrive()) {
inWindowsPath = true;
}
pathTail[2] = pathTail[1];
pathTail[1] = pathTail[0];
pathTail[0] = c;
}
}

bool endsInDrive() const {
return pathTail[0] == ':' && isDriveLetter(pathTail[1]) && !isWordCharacter(pathTail[2]);
}

bool endsInWindowsPath() {
scanWindowsPath();
return inWindowsPath || endsInDrive();
}

void pushNode(std::shared_ptr<MarkdownASTNode> node) {
flushText();
if (!node || !carriesWindowsPath(node->type))
resetWindowsPath();
if (node && !nodeStack.empty()) {
nodeStack.back()->addChild(node);
nodeStack.push_back(std::move(node));
Expand All @@ -132,13 +217,16 @@ class MD4CParser::Impl {

void popNode() {
flushText();
if (nodeStack.empty() || !carriesWindowsPath(nodeStack.back()->type))
resetWindowsPath();
if (nodeStack.size() > 1) {
nodeStack.pop_back();
}
}

void addInlineNode(std::shared_ptr<MarkdownASTNode> node) {
flushText();
resetWindowsPath();
if (node && !nodeStack.empty()) {
nodeStack.back()->addChild(node);
}
Expand Down Expand Up @@ -513,6 +601,15 @@ class MD4CParser::Impl {
return 0;
}

if (type == ENRMRKD_TEXT_ESCAPED) {
const bool inImage = std::any_of(impl->nodeStack.begin(), impl->nodeStack.end(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium parser/MD4CParser.cpp:605

With preserveWindowsPaths enabled, each ENRMRKD_TEXT_ESCAPED callback scans the entire nodeStack to check for an image, so deeply nested blockquotes followed by many escapes incur O(depth × escapes) work and can stall synchronous parsing. Track image nesting on node entry and exit instead of searching all ancestors for every escaped character.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/core/cpp/parser/MD4CParser.cpp around line 605:

With `preserveWindowsPaths` enabled, each `ENRMRKD_TEXT_ESCAPED` callback scans the entire `nodeStack` to check for an image, so deeply nested blockquotes followed by many escapes incur O(depth × escapes) work and can stall synchronous parsing. Track image nesting on node entry and exit instead of searching all ancestors for every escaped character.

[](const auto &node) { return node->type == NodeType::Image; });
if (!inImage && impl->endsInWindowsPath())
impl->currentText += '\\';
impl->currentText.append(text, size);
return 0;
}

// Handle text content (normal text, code text, LaTeX math, etc.)
if (type == ENRMRKD_TEXT_NORMAL || type == ENRMRKD_TEXT_CODE || type == ENRMRKD_TEXT_LATEXMATH) {
impl->currentText.append(text, size);
Expand Down Expand Up @@ -903,6 +1000,7 @@ std::shared_ptr<MarkdownASTNode> MD4CParser::parse(const std::string &markdown,

impl_->reset(estimatedDepth);
impl_->inputText = markdown.c_str();
impl_->trackWindowsPaths = md4cFlags.preserveWindowsPaths;

unsigned flags = ENRMRKD_FLAG_NOHTMLSPANS | ENRMRKD_FLAG_SPOILERS;
if (isGFM) {
Expand Down Expand Up @@ -935,6 +1033,9 @@ std::shared_ptr<MarkdownASTNode> MD4CParser::parse(const std::string &markdown,
if (md4cFlags.admonitions) {
flags |= ENRMRKD_FLAG_ADMONITIONS;
}
if (md4cFlags.preserveWindowsPaths) {
flags |= ENRMRKD_FLAG_ESCAPEDTEXT;
}

// Configure MD4C parser with callbacks
ENRMRKD_PARSER parser = {
Expand Down
5 changes: 4 additions & 1 deletion packages/core/cpp/parser/MD4CParser.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ struct Md4cFlags {
bool hardSoftBreaks = false;
bool preserveBlankLines = false;
bool admonitions = true;
// Keeps the backslash in prose such as `C:\Users\me\.config`, where CommonMark reads `\.` as an escape.
bool preserveWindowsPaths = false;

bool operator==(const Md4cFlags &other) const {
return underline == other.underline && latexMath == other.latexMath && superscript == other.superscript &&
subscript == other.subscript && highlight == other.highlight &&
permissiveAutolinks == other.permissiveAutolinks && hardSoftBreaks == other.hardSoftBreaks &&
preserveBlankLines == other.preserveBlankLines && admonitions == other.admonitions;
preserveBlankLines == other.preserveBlankLines && admonitions == other.admonitions &&
preserveWindowsPaths == other.preserveWindowsPaths;
}
};

Expand Down
1 change: 1 addition & 0 deletions packages/core/cpp/tests/ast_dump.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Markdown::Md4cFlags allEnabled() {
flags.hardSoftBreaks = true;
flags.preserveBlankLines = true;
flags.admonitions = true;
flags.preserveWindowsPaths = true;
return flags;
}

Expand Down
18 changes: 18 additions & 0 deletions packages/core/cpp/tests/fixtures/windows-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Saved to C:\Users\me\.t3\_build\shot.png and C:/work\.config and C:\folder\(draft.

A drive alone keeps it too: D:\.hidden

A space ends the path: C:\tmp \*not emphasis\*

Not a drive after a word: abC:\x\.y

Code stays literal: `C:\a\.b`

Link text [C:\x\.y](/p\.q "t\.u") and image ![C:\a\.b](i.png)

Formatting inside a path: C:/Users/**name**\.config and C:\a\_*b*\.c

Code ends a path: `C:\a`\.b

A line break ends it too: C:\tmp
\*not emphasis\*
Loading
Loading