Skip to content

feat(core): collect replaceable blocks and share the streaming filter - #1026

Merged
hryhoriiK97 merged 8 commits into
mainfrom
feat/block-renderers-core
Oct 22, 2026
Merged

hryhoriiK97 merged 8 commits into
mainfrom
feat/block-renderers-core

Conversation

@hryhoriiK97

Copy link
Copy Markdown
Collaborator

What/Why?

First of three stacked PRs that add custom block renderers to EnrichedMarkdownText (renderers and resolveImageSource). This one is the C++ core and ships inert: nothing consumes it until the native PR and the JS PR on top of it.

Stack: this PR → native hosts → JS API, docs and example.

  • BlockKinds: one table per replaceable kind (image paragraph, <video> block, link-only paragraph) with its node matcher, its name and its streaming line rule. The collector, the annotator and the streaming filter all iterate the table, so adding a kind is one entry here plus a margin entry per host.
  • BlockSlots: collects the slots a renderer may replace and every image occurrence, in document order, with stable ids (<kind>:<fnv1a64 of url>:<n>; slots and occurrences are counted separately so inline images coming and going do not shift block ids). One walk stamps the active ids on the AST. Nothing inside a list item is a slot, because the hosts lay lists out as a single run of text.
  • StreamingFilter: the math, table, code fence and media rules that iOS, Android and the JS collector previously kept as three copies now live once. New rules: a trailing image, link or <video> line is withheld while its syntax is open (hidden mode waits for the next block), a bare URL on its own line stays withheld until the next line (it can always grow), and an inline ![ or [ still being typed at the tail of the last paragraph is cut so it never flashes as literal text.
  • The parser keeps link titles ([label](url "title")), so a link block can carry metadata such as a file size.

Behaviour changes for existing streaming users: a half-streamed ![alt](https://… no longer renders as literal text for a tick, and link nodes now carry a title attribute. The golden AST dump changed only by those titles.

Testing

  • ENRM_SKIP_COEXISTENCE_CHECK=1 ./scripts/test-core-parser.sh: block_slots_test (eligibility edge cases, id stability under appends and inline insertions, annotation, the hash fixture that the web collector's Jest test asserts too) and the new streaming_filter_test (about 50 cases across math, tables, fences, media, links and inline tails).
  • Exercised end to end through the two PRs above on the iPhone 17 simulator and a Pixel 9 emulator.

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

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

Streaming filters mishandle valid content and unfinished syntax, while the unchanged WASM bundle conflicts with the updated golden AST.

7 open issues
What changed in this PR

Adds the C++ foundation for custom block renderers, ahead of native-host and JavaScript integration.

Changes:

  • Collects replaceable blocks and image occurrences with stable IDs.
  • Centralizes streaming filters and preserves link titles.
  • Adds regression tests, hash fixtures and stacked-PR CI coverage.
File Description
scripts/​test-core-parser.sh Runs collector and streaming tests.
packages/​core/​cpp/​tests/​streaming_filter_test.cpp Tests streaming rules.
packages/​core/​cpp/​tests/​golden/​ast.txt Records link titles.
packages/​core/​cpp/​tests/​fixtures/​block_slot_ids.txt Pins URL hashes.
packages/​core/​cpp/​tests/​block_slots_test.cpp Tests eligibility, IDs and annotation.
packages/​core/​cpp/​parser/​StreamingFilter.hpp Defines the shared filtering API.
packages/​core/​cpp/​parser/​StreamingFilter.cpp Implements streaming filters.
packages/​core/​cpp/​parser/​MD4CParser.cpp Preserves link titles.
packages/​core/​cpp/​parser/​BlockSlots.hpp Defines collection and annotation APIs.
packages/​core/​cpp/​parser/​BlockSlots.cpp Collects and annotates blocks and images.
packages/​core/​cpp/​parser/​BlockKinds.hpp Defines block-kind contracts.
packages/​core/​cpp/​parser/​BlockKinds.cpp Registers matchers and streaming rules.
.github/​workflows/​ci.yml Enables CI for stacked target branches.

Review effort: Balanced

Comment thread packages/core/cpp/parser/MD4CParser.cpp
Comment thread packages/core/cpp/parser/BlockKinds.cpp Outdated
Comment thread packages/core/cpp/parser/BlockKinds.cpp Outdated
Comment thread packages/core/cpp/parser/BlockKinds.cpp Outdated
Comment thread packages/core/cpp/parser/StreamingFilter.cpp Outdated
Comment thread packages/core/cpp/parser/StreamingFilter.cpp Outdated
Comment thread packages/core/cpp/parser/StreamingFilter.cpp Outdated

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

Streaming syntax handling can truncate valid content or expose unfinished media.

2 open issues
3 previously missed issues

In code that hasn't changed since last review

Medium severity Misses fenced code inside blockquotes

packages/​core/​cpp/​parser/​StreamingFilter.cpp:110

Quoted code fences are not recognized because parseFenceMarker() accepts only leading spaces. For > ```md\n> ![a](https://e, Progressive filtering consequently treats the second line as media and deletes it, although it is literal fenced-code content. Make fence tracking container-aware so media trimming skips code inside blockquotes and lists while preserving the original offsets.

Medium severity Truncates resolved reference links and images

packages/​core/​cpp/​parser/​StreamingFilter.cpp:249

Complete reference links and images are truncated as unfinished inline syntax. With [r]: https://e.com defined earlier, trailing See [docs][r] becomes See [docs] even in Progressive mode because the scanner selects the final [r]. Likewise, ![logo][r] becomes ![logo], removing the image. Recognize resolved full and collapsed reference forms before applying the tail cut, and add regression cases with earlier reference definitions.

Medium severity Fails to track multiline unfinished video tags

packages/​core/​cpp/​parser/​StreamingFilter.cpp:269

Checking only the last line releases unfinished multiline video syntax. For <video\n src="https://e, the last line matches neither a media classifier nor the inline-tail scanner, so the entire unfinished tag is returned in both modes. Multiline video tags are supported by tests/fixtures/html-blocks.md. Track the trailing media block across lines and withhold it from its opening line until the syntax closes.

7 resolved since last review

Review effort: Balanced

Comment thread packages/core/cpp/parser/BlockKinds.cpp Outdated
Comment thread packages/core/cpp/parser/BlockKinds.cpp Outdated

@eszlamczyk eszlamczyk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Went through the streaming filter with an eye on what it costs per tick, in particular for a long paragraph that arrives as a single line (no \n at all). Everything is linear in the document except the open-inline-syntax check, which is linear in links × last-line length; details inline, with measurements from a standalone build of StreamingFilter.cpp + BlockKinds.cpp at -O2. One correctness gap in the same area (soft line breaks) and one non-blocking nit about per-line copies.

Comment thread packages/core/cpp/parser/StreamingFilter.cpp Outdated
Comment thread packages/core/cpp/parser/BlockKinds.cpp Outdated
size_t close = labelEnd(text, bracket);
if (close == std::string::npos)
return BlockLineState::Open; // `[label` still typing
if (isBlank(text.substr(close + 1)))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

text.substr(close + 1) (and the same at line 227) allocates and copies the rest of the line just to ask whether it is blank, and this runs once per [ candidate from openInlineSyntaxStart. On a long single-line paragraph that is links × line length bytes of memcpy per tick. An index-based isBlankFrom(text, close + 1) does the same check with no allocation and usually stops at the first character.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5cc91e3: isBlankFrom(text, close + 1) in the new TextScan.hpp, no copy. BlockKinds takes string_view everywhere now.

Comment thread packages/core/cpp/parser/StreamingFilter.cpp Outdated
Comment thread packages/core/cpp/parser/StreamingFilter.cpp Outdated
Comment thread packages/core/cpp/parser/StreamingFilter.cpp Outdated
Comment thread packages/core/cpp/parser/StreamingFilter.cpp Outdated
hryhoriiK97 and others added 8 commits October 22, 2026 12:52
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…rs-*

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 <noreply@anthropic.com>
…aming

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ument

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@eszlamczyk
eszlamczyk force-pushed the feat/block-renderers-core branch from ea88889 to 3069c72 Compare October 22, 2026 10:56
@hryhoriiK97
hryhoriiK97 merged commit 8f124df into main Oct 22, 2026
18 checks passed
@hryhoriiK97
hryhoriiK97 deleted the feat/block-renderers-core branch October 22, 2026 13:33
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