Skip to content

feat(native): host React block renderers in the segmented markdown views - #1027

Merged
hryhoriiK97 merged 6 commits into
feat/block-renderers-corefrom
feat/block-renderers-native
Oct 22, 2026
Merged

hryhoriiK97 merged 6 commits into
feat/block-renderers-corefrom
feat/block-renderers-native

Conversation

@hryhoriiK97

Copy link
Copy Markdown
Collaborator

What/Why?

Second of three stacked PRs for custom block renderers. The iOS and Android GFM hosts can now reserve space for, and position, React children that stand in for image paragraphs, <video> blocks and link-only paragraphs. Still inert for apps: no JS sends slot children until the next PR.

Stack: #1026 (core) → this PR → JS API, docs and example.

  • A pure C++ TurboModule, EnrichedMarkdownParser (codegen type: "all", ios.modulesProvider, Android cxxModule* autolinking), gives JS the core's collector synchronously, with the streaming filter applied first, so slot children land in the same commit as the markdown they belong to.
  • The host shadow nodes measure each slot child with Yoga inside their own measure, lay the document out with placeholders, position the children in layout() (the ParagraphShadowNode pattern: cloneTree, layoutTree, setLayoutMetrics, DisplayType::None for unplaced slots) and publish {id, height, placed} through state. The view-initiated height update uses the callback form of updateState, so the two writers never clobber each other. The last slot measurement is memoized per node and invalidated on a children change, so Yoga's repeated callbacks and state-only commits do not re-parse.
  • Placeholders reserve the pixel-rounded child height with the margins of the block they replace; a pass-through slot layer hosts the Fabric children (touches and selection go straight through it).
  • imageSources (per-occurrence request URL and headers from resolveImageSource) reaches the image renderers, the downloader cache identity and the measurement cache key.
  • streamingConfig.mediaMode threaded to both hosts.
  • iOS renders once per mount transaction in finalizeUpdates (props and slot ids coalesce). The parser bridge memoizes the last three parses, so a streaming tick parses once instead of three times.
  • Android: one height walk for the root stack and nested quotes (measure and render cannot drift), margins rounded like the views, a synchronous render when the slot ids change so the placeholder lands in the same frame as the child, density in the measurement cache key, a ReactViewManager for the slot child (all view props), the slot layer drawn last by drawing order instead of being reordered every render, and the same parse memo.
  • common/ENRMReactHeaders.h reaches React Native through <React/View.h>, <React/RendererCore.h> and <React/Debug.h> where they exist (the 0.88 strict C++ API) and falls back to the fine-grained headers otherwise.
  • The old hand-written android/src/main/jni/EnrichedMarkdownTextSpec.{cpp,h} is removed; codegen generates it now.

Known limits, listed in the follow-ups: the arriving block lands at full height in one frame (height animation is a separate PR), the iOS slot layer bypasses RCTViewComponentView's clipping bookkeeping.

Testing

  • BlockSlotReconcilerTest (Android, Robolectric): a reused placeholder takes the new slot id. BlockSlotStackingTests (iOS XCTest through the example app's test target): placement, recorded frames, per-kind margins.
  • Both apps built and driven on the iPhone 17 simulator and a Pixel 9 emulator: every placement, duplicate URLs, removal and restore, rapid renderer toggles, unmount mid-stream, rotation, Dynamic Type at the largest size, a live Android font-scale change, streaming in both media modes. The accessibility-tree check in the next PR's qa-renderers-streaming flow passed twice on each platform.
  • yarn lint-clang, yarn lint-kotlin, Android unit tests (35).

PR Checklist

  • Code compiles and runs on iOS
  • Code compiles and runs on Android
  • Updated documentation/README if applicable (in the JS PR of this stack)
  • Ran example app to verify changes
  • E2E tests are passing
  • Required E2E tests have been added (if applicable)

🤖 Generated with Claude Code

@hryhoriiK97
hryhoriiK97 added this pull request to stack #1029 October 17, 2026 13:28
@hryhoriiK97
hryhoriiK97 force-pushed the feat/block-renderers-native branch 3 times, most recently from d42f18a to 159137a Compare October 17, 2026 13:42
@hryhoriiK97
hryhoriiK97 requested review from eszlamczyk and a balanced review from Copilot October 17, 2026 13:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved slot identity, layout synchronization, image refresh, and release-build JNI issues affect correctness.

8 open issues
What changed in this PR

Adds native hosting for React block replacements, connecting the core collector from #1026 to iOS and Android segmented markdown views. The public JavaScript API remains deferred to the next PR.

Changes:

  • Adds a synchronous parser TurboModule and Fabric slot components.
  • Measures and positions replacement children, including nested blocks.
  • Threads image-source overrides and shared streaming rules through both hosts.

Paths are relative to packages/react-native-enriched-markdown/.

File Description
src/​NativeEnrichedMarkdownParser.ts Defines synchronous collector interface.
src/​EnrichedMarkdownTextNativeComponent.ts Adds media streaming option.
src/​EnrichedMarkdownNativeComponent.ts Adds image overrides and media option.
src/​EnrichedMarkdownBlockSlotNativeComponent.ts Defines Fabric slot component.
ReactNativeEnrichedMarkdown.podspec Includes shared C++ module sources.
react-native.config.js Registers slots and parser autolinking.
package.json Configures module and component codegen.
ios/​utils/​StreamingMarkdownFilter.mm Bridges shared core streaming filter.
ios/​utils/​StreamingMarkdownFilter.m Removes duplicated filter implementation.
ios/​utils/​StreamingMarkdownFilter.h Declares media filtering options.
ios/​utils/​ImageRequestHeaderUtils.h Converts image-source override props.
ios/​utils/​HeightUpdateUtils.h Preserves placements during height updates.
ios/​utils/​ENRMImageDownloader.m Merges request headers case-insensitively.
ios/​utils/​ENRMImageDownloader.h Declares header merge helper.
ios/​turbomodule/​EnrichedMarkdownParserProvider.mm Instantiates parser TurboModule.
ios/​turbomodule/​EnrichedMarkdownParserProvider.h Declares module provider.
ios/​styles/​StyleConfig.mm Stores and copies image overrides.
ios/​styles/​StyleConfig.h Defines image override configuration.
ios/​slots/​ENRMBlockSlotLayerView.m Passes background touches through.
ios/​slots/​ENRMBlockSlotLayerView.h Declares slot overlay layer.
ios/​slots/​EnrichedMarkdownBlockSlot.mm Implements Fabric slot view.
ios/​slots/​EnrichedMarkdownBlockSlot.h Declares slot view identity.
ios/​segments/​SegmentRenderer.m Creates replacement slot segments.
ios/​segments/​RenderedMarkdownSegment.m Constructs slot segment models.
ios/​segments/​RenderedMarkdownSegment.h Defines slot segment types.
ios/​segments/​ENRMSegmentHeightMeasurer.mm Measures slots and records origins.
ios/​segments/​ENRMSegmentHeightMeasurer.h Extends measurement interface.
ios/​segments/​ENRMContainerNodeView.mm Applies slot heights and margins.
ios/​segments/​ENRMBlockSlotSegmentView.m Implements placeholders and child binding.
ios/​segments/​ENRMBlockSlotSegmentView.h Declares placeholder and margin helpers.
ios/​segments/​ENRMBlockquoteContainerView.mm Supports slots inside quotes.
ios/​segments/​ENRMBlockquoteContainerView.h Extends nested measurement interface.
ios/​renderer/​ENRMImageRenderer.m Resolves per-occurrence image overrides.
ios/​parser/​MarkdownParserBridge.mm Annotates slots and caches parses.
ios/​parser/​ENRMMarkdownParser.mm Forwards annotation options.
ios/​parser/​ENRMMarkdownParser.h Exposes annotation options.
ios/​internals/​MeasurementCache.h Includes image overrides in cache keys.
ios/​internals/​ENRMViewFreeMeasurement.h Integrates slots into document measurement.
ios/​internals/​ENRMBlockSlotMeasurer.mm Records measured slot frames.
ios/​internals/​ENRMBlockSlotMeasurer.h Defines slot measurement callback.
ios/​internals/​EnrichedMarkdownTextState.h Supports callback-based height updates.
ios/​internals/​EnrichedMarkdownTextShadowNode.h Uses shared React header compatibility.
ios/​internals/​EnrichedMarkdownTextComponentDescriptor.h Uses shared React header compatibility.
ios/​internals/​EnrichedMarkdownState.h Stores slot placement state.
ios/​internals/​EnrichedMarkdownShadowNode.mm Measures and positions slot children.
ios/​internals/​EnrichedMarkdownShadowNode.h Declares slot layout and memoization.
ios/​internals/​EnrichedMarkdownComponentDescriptor.h Uses shared React header compatibility.
ios/​input/​internals/​EnrichedMarkdownTextInputState.h Supports callback-based height updates.
ios/​input/​internals/​EnrichedMarkdownTextInputShadowNode.h Uses shared React header compatibility.
ios/​input/​internals/​EnrichedMarkdownTextInputComponentDescriptor.h Uses shared React header compatibility.
ios/​EnrichedMarkdown.mm Mounts slots and coalesces render requests.
ios/​attachments/​ENRMImageAttachment.m Separates original and request URLs.
ios/​attachments/​ENRMImageAttachment.h Exposes image override attachment API.
common/​ENRMReactHeaders.h Provides React header compatibility.
common/​EnrichedMarkdownParser.h Defines shared parser TurboModule.
common/​EnrichedMarkdownParser.cpp Filters markdown and collects blocks.
android/​src/​test/​java/​com/​swmansion/​enriched/​markdown/​segments/​BlockSlotReconcilerTest.kt Tests placeholder reuse and identity.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​MarkdownTextState.h Preserves slot placement state.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​MarkdownTextState.cpp Serializes placements for Kotlin.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​MarkdownTextShadowNode.h Uses shared React header compatibility.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​MarkdownTextInputShadowNode.h Uses shared React header compatibility.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​MarkdownContainerShadowNode.h Declares slot layout and memoization.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​MarkdownContainerShadowNode.cpp Measures and positions slot children.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​MarkdownContainerMeasurementManager.h Accepts slot ids during measurement.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​MarkdownContainerMeasurementManager.cpp Passes slot ids to Kotlin.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​conversions.h Serializes image overrides and flavor.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​ComponentDescriptors.h Registers slot component descriptor.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​BlockSlotMeasureSession.h Defines native slot measurement session.
android/​src/​main/​jni/​react/​renderer/​components/​EnrichedMarkdownTextSpec/​BlockSlotMeasureSession.cpp Measures children and records frames.
android/​src/​main/​jni/​parser-module/​EnrichedMarkdownParserAutolink.cpp Supplies autolinking translation unit.
android/​src/​main/​jni/​parser-module/​CMakeLists.txt Links parser autolinking target.
android/​src/​main/​jni/​EnrichedMarkdownTextSpec.h Removes handwritten module declaration.
android/​src/​main/​jni/​EnrichedMarkdownTextSpec.cpp Removes handwritten module provider.
android/​src/​main/​jni/​CMakeLists.txt Compiles shared parser module.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​utils/​text/​ImageDownloader.kt Bypasses HTTP cache for header-bearing requests.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​utils/​common/​StreamingMarkdownFilter.kt Delegates filtering to native core.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​utils/​common/​MarkdownViewManagerUtils.kt Parses image-source override props.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​styles/​StyleConfig.kt Includes image overrides in configuration.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​styles/​ImageSourceOverride.kt Defines image request override model.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​spans/​ImageSpan.kt Applies overridden requests and cache identities.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​slots/​EnrichedMarkdownBlockSlotView.kt Hosts React replacement children.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​slots/​EnrichedMarkdownBlockSlotManager.kt Exposes slot identity and view props.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​slots/​BlockSlotLayer.kt Separates slots from native segments.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​segments/​SegmentViewCreators.kt Creates and updates slot placeholders.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​segments/​SegmentSignature.kt Adds slot signature salt.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​segments/​SegmentHeightMeasurer.kt Shares root and nested measurement.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​segments/​RenderedSegment.kt Adds rendered slot model.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​segments/​MarkdownSegment.kt Splits annotated blocks into slots.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​segments/​ContainerNodeView.kt Propagates heights and offsets segment indices.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​segments/​BlockSlotSegmentView.kt Reserves replacement height and margins.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​segments/​BlockSlotMeasurer.kt Defines slot measurement callback.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​segments/​BlockSlotKind.kt Maps slot kinds to margins.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​segments/​BlockquoteContainerView.kt Supports nested slot placement.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​renderer/​NodeRenderer.kt Passes image overrides to spans.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​renderer/​ImageRenderer.kt Forwards image occurrence ids.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​parser/​Parser.kt Adds annotation options and parse memoization.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​MeasurementStore.kt Integrates slot measurement and cache inputs.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​EnrichedMarkdownTextPackage.kt Registers slot view manager.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​EnrichedMarkdownManager.kt Mounts children and forwards slot state.
android/​src/​main/​java/​com/​swmansion/​enriched/​markdown/​EnrichedMarkdown.kt Hosts slots and synchronizes topology changes.
android/​src/​main/​cpp/​jni-adapter.cpp Bridges annotation, filtering, and slot measurement.
tests/​ios/​BlockSlotStackingTests.mm Tests placement, frames, and margins.

Review effort: Balanced

@hryhoriiK97
hryhoriiK97 force-pushed the feat/block-renderers-native branch from 159137a to d6d78a4 Compare October 17, 2026 14:01
@hryhoriiK97
hryhoriiK97 requested a balanced review from Copilot October 17, 2026 18:54
@hryhoriiK97
hryhoriiK97 force-pushed the feat/block-renderers-native branch from f531612 to 844c3b6 Compare October 17, 2026 19:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@hryhoriiK97
hryhoriiK97 force-pushed the feat/block-renderers-native branch from f983fcd to 66c71f0 Compare October 19, 2026 10:54
hryhoriiK97 and others added 6 commits October 22, 2026 12:55
The iOS and Android GFM hosts can now reserve space for, and position, React
children that stand in for image paragraphs, video blocks and link-only
paragraphs. Inert until the JS layer sends slot children.

- A pure C++ TurboModule (EnrichedMarkdownParser, codegen type "all") gives JS
  the core's collector synchronously, with the streaming filter applied first,
  so slot children land in the same commit as the markdown.
- The host shadow nodes measure each slot child with Yoga inside their own
  measure, lay the document out with placeholders, position the children in
  layout() (the ParagraphShadowNode pattern) and publish {id, height, placed}
  through state. The last slot measurement is memoized per node and
  invalidated on a children change.
- Placeholders reserve the pixel-rounded child height with the margins of the
  block they replace; a pass-through slot layer hosts the Fabric children.
- imageSources (per-occurrence request URL and headers) flows into the image
  renderers and the measurement cache key.
- streamingConfig.mediaMode, threaded to both hosts' filters.
- iOS renders once per mount transaction in finalizeUpdates; the parser
  bridge memoizes the last parses so a streaming tick parses once.
- Android: one height walk for the root stack and nested quotes, margins
  rounded like the views, synchronous render when slot ids change, density
  in the measurement cache key, a ReactViewManager for the slot child, and
  the slot layer drawn last by drawing order.
- common/ENRMReactHeaders.h reaches React Native through its umbrella
  headers where they exist (0.88 strict C++ API) and falls back otherwise.

Tests: BlockSlotReconcilerTest (Android) and BlockSlotStackingTests (iOS).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…ment

Review follow-ups on the block slot hosts:

- Keep StreamingFilterResult for R8 and clear the pending JNI exception when
  the class cannot be built, so a minified build gets null, not a throw.
- Annotate slots before text-link recognition on both platforms; the JS
  collector never runs recognition, so both now count the same slots.
- The Android split-measure cache key carries the slot ids: a size measured
  with React children never serves the slot-free document.
- Both layout() overrides re-measure when the props or height counter
  changed under an explicitly sized host where Yoga skipped measureContent.
- iOS renders synchronously when the slot ids change, in the mount
  transaction that places the React child, as the Android host does.
- Image source overrides join the text and blockquote segment signatures,
  so a reused segment never keeps the previous source's attachment.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
The streaming config travels with the measurement props (conversions.h)
instead of a per-view map the manager filled after the first measurement,
so the first layout and a layout after a mode change withhold exactly what
the view withholds, for tables, code blocks and media alike. On iOS the
table segment signature carries the image source overrides too, as the text
and blockquote signatures do.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
A clone with new children, which is every frame of an animation running
inside a slot, dropped the slot measurement and re-measured the whole
document on the UI thread. The clone now only asks the next pass to
re-measure the slot children through Yoga, and the measurement is kept
when their heights match. In the example app's streaming journal the
Android emulator went from 98% janky frames to 4.7%.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…and rounding

The hosts filter and parse through the core DocumentStore, so the view and
the shadow node reuse the document JS collect already parsed. Slot ids, image
ids and recognized links go on during conversion (AstOverlay) instead of
being written into the shared tree. JNI strings cross as UTF-16 so ids hash
the same bytes as on the JS side.

Android: the slot layer is now the last child instead of being drawn last,
since TouchTargetHelper ignores drawing order and a Pressable in a slot never
got its press. TalkBack reads each slot child where its placeholder is.
Slot heights and frames round instead of ceil: they are already whole pixels,
and ceil turned float error into an extra pixel at 2.625 or 3.5. Only placed
slot children replace their block, so a failed split measurement keeps the
native blocks instead of dropping them.

iOS: the shadow node reuses its slot measurement across widths within a
pixel, a recycled view forgets a pending synchronous render, and segments go
beside the slot layer when the view wraps its subviews in a container.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A blockquote is long-clickable for its context menu, which makes it
important for accessibility, so the host added it as itself and the
framework listed its children the default way: the placeholder is hidden
and the slot view lives in the host's slot layer, so the renderer was
never reached. Every container now substitutes slot views when it lists
its own children, finding them through the host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@eszlamczyk
eszlamczyk force-pushed the feat/block-renderers-native branch from a7003c4 to 2b1f082 Compare October 22, 2026 10:56
@hryhoriiK97
hryhoriiK97 merged commit 52481e5 into main Oct 22, 2026
22 checks passed
@hryhoriiK97
hryhoriiK97 deleted the feat/block-renderers-native branch October 22, 2026 13:33
hryhoriiK97 added a commit that referenced this pull request Oct 22, 2026
…#1026)

* feat(core): collect replaceable blocks and share the streaming filter

Adds the C++ side of custom block renderers for EnrichedMarkdownText:

- BlockKinds: one table per replaceable kind (image paragraph, video block,
  link-only paragraph) with its node matcher, naming and streaming line rule.
  The collector, the annotator and the streaming filter iterate it, so a new
  kind is one entry here plus a margin entry per host.
- BlockSlots: collects slots and image occurrences in document order with
  stable ids (`<kind>:<fnv1a64 of url>:<n>`, slots and occurrences counted
  separately) and stamps the active ones on the AST in one walk. Nothing
  inside a list item is a slot, since the hosts lay lists out as text.
- StreamingFilter: the math, table, code fence and media rules iOS, Android
  and the JS collector all shared by copy now live once. Trailing image,
  link and video lines are withheld while their syntax is open (hidden mode
  waits for the next block), a bare URL stays withheld until the next line,
  and an inline image or link still being typed at the tail of the last
  paragraph is cut so it never flashes as text.
- The parser keeps link titles, so a link block can carry metadata.

Behaviour changes for existing streaming users: a trailing partial
`![alt](https://…` no longer renders as literal text, and `[label](url "t")`
links now carry their title in the AST.

Tests: block_slots_test (eligibility, id stability, annotation, the hash
fixture shared with the web collector) and streaming_filter_test.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* chore(web): rebuild the wasm parser bundle

The parser now keeps link titles, so the committed bundle has to be rebuilt
for scripts/test-web-bundle.mjs to agree with the golden AST dump.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* ci: run the pull request checks for PRs stacked on feat/block-renderers-*

The CI workflow only triggers for pull requests into main, so the upper PRs
of the block renderers stack (#1027 onto #1026's branch, #1028 onto #1027's)
got no checks. Temporary; remove once the stack has merged.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(core): read inline link syntax the way CommonMark does while streaming

Review findings on the streaming classifier, all with regression cases:

- a closed bracket followed by prose (`[INFO] done`) is prose, not an open
  link, so the line is no longer withheld;
- a link destination closes at its balanced `)`, so nested parentheses and
  titles with parentheses classify correctly in both the line classifier
  and the inline-tail scanner, which now share one reader;
- a closed autolink followed by text is prose, not a block;
- four or more spaces of indentation make the line indented code, which no
  classifier matches and the tail scanner leaves alone;
- an escaped bracket or bang is literal text;
- code spans are matched by backtick-run length, so a double-backtick span
  keeps its brackets.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(core): read nested labels and link titles while streaming

The inline syntax helpers now follow CommonMark further:

- A label closes at its balanced `]`, skipping escapes and code spans, so
  `[![thumb](u)](v)` and `[a [b]](u)` classify as link paragraphs and an
  outer link still typing around a closed image is cut, not shown as text.
- A destination may be `<pointy>` and may carry a quoted or parenthesised
  title; parentheses inside the title no longer count as delimiters, so
  `[a](u "t(")` is complete and `[a](u "t )` stays open.
- The tail scanner walks left over the `[` candidates until one is open.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(core): withhold a block line cut off inside its opening syntax

A stream that stopped at `<`, `<vid`, `<htt` or a lone `!` showed those
characters as text for a tick before the line became a video, link or
image block. A strict prefix of `<video`, `<http` or `![` on the trailing
line is now open syntax, like a half-typed URL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(core): filter the stream in one pass and share one parse per document

The streaming filter split the document into copied lines up to three times
per tick, and the open-inline-syntax check rescanned the last line for every
`[`, which made a long single-line paragraph cost links x line length. It
now scans blocks once over string_view lines and makes one inline pass over
the trailing paragraph, so a label that wraps onto a second line is cut too.
A fuzz test checks every streamed prefix against md4c and a bench fails if
a pathological tail gets much slower than prose.

DocumentStore keeps the last filtered and parsed documents (16 entries, 8 MB)
so JS collect, the shadow node and the view share one filter and one parse
per tick. The parsed tree is never written to after the parse.

Block ids now hash kind, url, text and title, and image occurrences use their
own `img:` prefix, so a block id can no longer equal an occurrence id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(core): start the trailing paragraph after blocks that end it

The inline-syntax pass walked back to the last blank line, so a stray `[`
in a heading, a setext heading or a quote above the trailing paragraph
withheld everything after it. The walk now stops at headings, underlines,
thematic breaks, deeper quotes and HTML blocks.

A `](` inside a destination already found to be prose no longer rescans
it, which kept `[x](` repeated linear. Both shapes are in the bench.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Ernest <[email protected]>
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.

3 participants