From aaf98f39d6f0690d385737d5f1413e93697468b3 Mon Sep 17 00:00:00 2001
From: Enrico Kalkbrenner <758813+ek9r@users.noreply.github.com>
Date: Wed, 5 Aug 2026 21:05:06 +0200
Subject: [PATCH 1/2] Add parseMany() for multi-content Markdown
Splits content into multiple independently-parsed parts via < next --> / < next: name --> markers.
Non-breaking: ParsedMarkdown gets an optional $name, and parseMany() returns a ParsedMarkdownCollection (mirrors the existing TokenCollection).
---
README.md | 43 +++++
src/Markdown.php | 20 +-
src/MultiMarkdownSplitter.php | 70 +++++++
src/ParsedMarkdown.php | 1 +
src/ParsedMarkdownCollection.php | 84 +++++++++
tests/Fixtures/infrastructure.md | 66 +++++++
tests/MarkdownParseManyTest.php | 151 +++++++++++++++
tests/MarkdownTest.php | 17 ++
tests/MultiMarkdownSplitterTest.php | 250 +++++++++++++++++++++++++
tests/ParsedMarkdownCollectionTest.php | 112 +++++++++++
10 files changed, 812 insertions(+), 2 deletions(-)
create mode 100644 src/MultiMarkdownSplitter.php
create mode 100644 src/ParsedMarkdownCollection.php
create mode 100644 tests/Fixtures/infrastructure.md
create mode 100644 tests/MarkdownParseManyTest.php
create mode 100644 tests/MultiMarkdownSplitterTest.php
create mode 100644 tests/ParsedMarkdownCollectionTest.php
diff --git a/README.md b/README.md
index d9870e9..0aac7bd 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,49 @@ echo $parsed->html;
You can read more in [the docs](https://tempestphp.com/3.x/packages/markdown).
+### Multi-Content
+
+Need to keep multiple content parts together? Separate them with a
+`` marker. The simplest case doesn't even need frontmatter:
+
+```md
+First part.
+
+Second part.
+```
+
+Frontmatter stays optional per part, and naming a part makes it directly
+reachable, no need to search for it:
+
+```md
+---
+title: Pancakes
+---
+Mix flour, eggs, and milk.
+
+
+---
+title: Waffles
+---
+Mix flour, eggs, and butter.
+```
+
+```php
+$chunks = $markdown->parseMany($content);
+
+echo $chunks[0]->frontmatter['title']; // Pancakes
+echo $chunks['recipe-2']->frontmatter['title']; // Waffles
+```
+
+This works well for anything naturally made up of independent parts, no
+matter where the content comes from: a tiny blog, the slides of a
+presentation, page separators in a long document, or a small infrastructure
+topology, for instance. Just like `parse()`, `parseMany()` only ever works on
+the string you hand it; reading that content from a file, a database, or
+anywhere else is entirely up to the caller. A larger example, modeling hosts,
+services, and an application with dependencies between them, is available in
+[`tests/Fixtures/infrastructure.md`](tests/Fixtures/infrastructure.md).
+
## Performance
This package began as a challenge to make a more performant Markdown parser in pure PHP. The primary performance gain is from not relying on regex but instead using a simple lexer to tokenize Markdown files and convert them to HTML.
diff --git a/src/Markdown.php b/src/Markdown.php
index f40bf71..c62e743 100644
--- a/src/Markdown.php
+++ b/src/Markdown.php
@@ -9,6 +9,8 @@ final class Markdown
{
private Parser $parser;
+ private MultiMarkdownSplitter $splitter;
+
public function __construct(
public ?Highlighter $highlighter = new Highlighter(),
private ?ResponsiveImageFactory $imageFactory = null,
@@ -19,11 +21,25 @@ public function __construct(
$this->imageFactory,
$this->maxNestingDepth,
);
+
+ $this->splitter = new MultiMarkdownSplitter();
}
- public function parse(string $content): ParsedMarkdown
+ public function parse(string $content, ?string $name = null): ParsedMarkdown
{
- return $this->parser->parse($content);
+ $parsed = $this->parser->parse($content);
+
+ return $name === null ? $parsed : new ParsedMarkdown($parsed->html, $parsed->frontmatter, $name);
+ }
+
+ public function parseMany(string $content, ?string $baseName = null, string $keyword = 'next'): ParsedMarkdownCollection
+ {
+ $chunks = $this->splitter->split($content, $baseName, $keyword);
+
+ return new ParsedMarkdownCollection(array_map(
+ fn (array $chunk): ParsedMarkdown => $this->parse($chunk['content'], $chunk['name']),
+ $chunks,
+ ));
}
public function withRules(Rule ...$rules): self
diff --git a/src/MultiMarkdownSplitter.php b/src/MultiMarkdownSplitter.php
new file mode 100644
index 0000000..7dfca29
--- /dev/null
+++ b/src/MultiMarkdownSplitter.php
@@ -0,0 +1,70 @@
+
+ */
+ public function split(string $content, ?string $baseName = null, string $keyword = 'next'): array
+ {
+ preg_match_all($this->markerPattern($keyword), $content, $matches, PREG_OFFSET_CAPTURE);
+
+ $markers = [];
+
+ foreach ($matches[0] as $i => [$fullMatch, $offset]) {
+ $markers[] = [
+ 'rawName' => trim($matches[1][$i][0] ?? ''),
+ 'start' => $offset,
+ 'end' => $offset + strlen($fullMatch),
+ ];
+ }
+
+ if ($markers === []) {
+ return [['name' => null, 'content' => trim($content)]];
+ }
+
+ $chunks = [];
+ $position = 0;
+
+ $leading = trim(substr($content, 0, $markers[0]['start']));
+
+ if ($leading !== '') {
+ $position++;
+ $chunks[] = ['name' => $this->resolveMarkerName('', $baseName, $position), 'content' => $leading];
+ }
+
+ foreach ($markers as $index => $marker) {
+ $isAuto = $marker['rawName'] === '' || $marker['rawName'] === '*';
+
+ $end = $markers[$index + 1]['start'] ?? strlen($content);
+ $chunk = trim(substr($content, $marker['end'], $end - $marker['end']));
+
+ if ($isAuto && $chunk === '') {
+ continue;
+ }
+
+ $position++;
+ $chunks[] = ['name' => $this->resolveMarkerName($marker['rawName'], $baseName, $position), 'content' => $chunk];
+ }
+
+ return $chunks;
+ }
+
+ private function markerPattern(string $keyword): string
+ {
+ $escaped = preg_quote($keyword, '/');
+
+ return "/^[ \\t]*[ \\t]*\\r?\\n?/m";
+ }
+
+ private function resolveMarkerName(string $rawName, ?string $baseName, int $position): string
+ {
+ if ($rawName !== '' && $rawName !== '*') {
+ return $rawName;
+ }
+
+ return $baseName === null ? "chunk-{$position}" : "{$baseName}-{$position}";
+ }
+}
diff --git a/src/ParsedMarkdown.php b/src/ParsedMarkdown.php
index c288d14..be25abe 100644
--- a/src/ParsedMarkdown.php
+++ b/src/ParsedMarkdown.php
@@ -9,6 +9,7 @@
public function __construct(
public string $html,
public array $frontmatter,
+ public ?string $name = null,
) {}
public function __toString(): string
diff --git a/src/ParsedMarkdownCollection.php b/src/ParsedMarkdownCollection.php
new file mode 100644
index 0000000..2e6a114
--- /dev/null
+++ b/src/ParsedMarkdownCollection.php
@@ -0,0 +1,84 @@
+
+ * @implements ArrayAccess
+ */
+final class ParsedMarkdownCollection implements IteratorAggregate, ArrayAccess, Countable
+{
+ /** @var list */
+ private array $chunks = [];
+
+ /** @var array */
+ private array $byName = [];
+
+ public function __construct(array $chunks = [])
+ {
+ foreach ($chunks as $chunk) {
+ $this->add($chunk);
+ }
+ }
+
+ public function add(ParsedMarkdown $chunk): self
+ {
+ $index = count($this->chunks);
+ $this->chunks[$index] = $chunk;
+
+ if ($chunk->name !== null) {
+ $this->byName[$chunk->name] = $index;
+ }
+
+ return $this;
+ }
+
+ public function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->chunks);
+ }
+
+ public function offsetExists(mixed $offset): bool
+ {
+ return is_string($offset) ? isset($this->byName[$offset]) : isset($this->chunks[$offset]);
+ }
+
+ public function offsetGet(mixed $offset): ?ParsedMarkdown
+ {
+ if (is_string($offset)) {
+ return isset($this->byName[$offset]) ? $this->chunks[$this->byName[$offset]] : null;
+ }
+
+ return $this->chunks[$offset] ?? null;
+ }
+
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ if ($offset !== null) {
+ throw new InvalidArgumentException(
+ 'ParsedMarkdownCollection does not support setting an explicit offset. '
+ . 'Use $collection[] = $chunk or add($chunk) instead — a chunk\'s name '
+ . '(if any) is what makes it reachable by name, not the assignment key.',
+ );
+ }
+
+ $this->add($value);
+ }
+
+ public function offsetUnset(mixed $offset): void
+ {
+ // unsupported
+ }
+
+ public function count(): int
+ {
+ return count($this->chunks);
+ }
+}
diff --git a/tests/Fixtures/infrastructure.md b/tests/Fixtures/infrastructure.md
new file mode 100644
index 0000000..d744130
--- /dev/null
+++ b/tests/Fixtures/infrastructure.md
@@ -0,0 +1,66 @@
+
+---
+type: host
+status: up
+---
+# Primary Web Node
+
+Primary application server, running in the **eu-central-1** region.
+
+- OS: Ubuntu 26.04 LTS
+- vCPUs: 4
+- Memory: 8 GiB
+
+
+---
+type: host
+status: up
+---
+# Primary Database Node
+
+Primary database server, same region as `web-1`.
+
+- OS: Ubuntu 26.04 LTS
+- vCPUs: 8
+- Memory: 32 GiB
+
+
+---
+type: service
+status: up
+runs_on: [web-1]
+depends_on: [db-service]
+---
+# Web Frontend
+
+Handles incoming HTTP traffic and renders the storefront pages.
+
+Restarting this service is safe during business hours: it drains
+existing connections gracefully within **30 seconds** before shutting down.
+
+
+---
+type: service
+status: up
+runs_on: [db-1]
+---
+# Database Layer
+
+Owns the primary datastore for products, orders, and sessions.
+
+Backups run nightly at `02:00 UTC` and are retained for 30 days.
+
+
+---
+type: application
+status: up
+composed_of: [web-service, db-service]
+---
+# Storefront Application
+
+The customer-facing storefront application, composed of:
+
+- `web-service`, for the public-facing pages
+- `db-service`, for persistence
+
+See the [status page](https://status.example.com) for current uptime.
diff --git a/tests/MarkdownParseManyTest.php b/tests/MarkdownParseManyTest.php
new file mode 100644
index 0000000..8db9602
--- /dev/null
+++ b/tests/MarkdownParseManyTest.php
@@ -0,0 +1,151 @@
+markdown = new Markdown();
+ }
+
+ #[Test]
+ public function test_parse_many_without_markers_behaves_like_parse(): void
+ {
+ $chunks = $this->markdown->parseMany('**Hello**');
+
+ $this->assertCount(1, $chunks);
+ $this->assertNull($chunks[0]->name);
+ $this->assertSame('Hello
', $chunks[0]->html);
+ }
+
+ #[Test]
+ public function test_parse_many_gives_each_document_its_own_frontmatter(): void
+ {
+ $chunks = $this->markdown->parseMany(<<
+ ---
+ name: web-1
+ ---
+ Web host.
+ MD);
+
+ $this->assertCount(2, $chunks);
+
+ $this->assertSame('chunk-1', $chunks[0]->name);
+ $this->assertSame('db-primary', $chunks[0]->frontmatter['name']);
+ $this->assertStringContainsString('Primary database', $chunks[0]->html);
+
+ $this->assertSame('hosts/web-1.md', $chunks[1]->name);
+ $this->assertSame('web-1', $chunks[1]->frontmatter['name']);
+ $this->assertStringContainsString('Web host', $chunks[1]->html);
+ }
+
+ #[Test]
+ public function test_marker_at_the_start_does_not_break_frontmatter_parsing(): void
+ {
+ $chunks = $this->markdown->parseMany(<<
+ ---
+ title: A
+ ---
+ Body
+ MD);
+
+ $this->assertCount(1, $chunks);
+ $this->assertSame('readme.md', $chunks[0]->name);
+ $this->assertSame('A', $chunks[0]->frontmatter['title']);
+ }
+
+ #[Test]
+ public function test_marker_at_the_start_tolerates_arbitrary_blank_lines_before_frontmatter(): void
+ {
+ $tight = $this->markdown->parseMany("\n---\ntitle: A\n---\nBody");
+ $loose = $this->markdown->parseMany("\n\n\n---\ntitle: A\n---\nBody");
+
+ $this->assertSame($tight[0]->frontmatter, $loose[0]->frontmatter);
+ $this->assertSame($tight[0]->html, $loose[0]->html);
+ }
+
+ #[Test]
+ public function test_a_document_without_frontmatter_still_works(): void
+ {
+ $chunks = $this->markdown->parseMany(<<
+ Just prose, no frontmatter here.
+ MD, baseName: 'notes.md');
+
+ $this->assertSame('notes.md-1', $chunks[0]->name);
+ $this->assertSame([], $chunks[0]->frontmatter);
+ }
+
+ #[Test]
+ public function test_the_name_after_the_marker_can_be_a_plain_identifier_instead_of_a_path(): void
+ {
+ $chunks = $this->markdown->parseMany(<<
+ ---
+ status: up
+ ---
+ Primary database.
+ MD);
+
+ $this->assertSame('db-primary', $chunks[0]->name);
+ $this->assertSame('up', $chunks[0]->frontmatter['status']);
+ }
+
+ #[Test]
+ public function test_known_limitation_marker_inside_a_code_fence_is_still_split(): void
+ {
+ $chunks = $this->markdown->parseMany(<<
+ ```
+
+ More text.
+ MD);
+
+ $this->assertGreaterThan(1, count($chunks));
+ }
+
+ #[Test]
+ public function test_the_infrastructure_fixture_parses_into_a_consistent_topology(): void
+ {
+ $content = file_get_contents(__DIR__ . '/Fixtures/infrastructure.md');
+ $chunks = $this->markdown->parseMany($content);
+
+ $byName = [];
+
+ foreach ($chunks as $chunk) {
+ $byName[$chunk->name] = $chunk->frontmatter;
+ }
+
+ $this->assertCount(5, $chunks);
+ $this->assertSame(['web-1', 'db-1', 'web-service', 'db-service', 'storefront'], array_keys($byName));
+
+ foreach ($byName as $frontmatter) {
+ foreach (['runs_on', 'depends_on', 'composed_of'] as $relation) {
+ foreach ($frontmatter[$relation] ?? [] as $reference) {
+ $this->assertArrayHasKey($reference, $byName);
+ }
+ }
+ }
+
+ $this->assertSame(['web-1'], $byName['web-service']['runs_on']);
+ $this->assertSame(['web-service', 'db-service'], $byName['storefront']['composed_of']);
+ }
+}
diff --git a/tests/MarkdownTest.php b/tests/MarkdownTest.php
index fcdc618..39893b7 100644
--- a/tests/MarkdownTest.php
+++ b/tests/MarkdownTest.php
@@ -296,4 +296,21 @@ public function test_raw(): void
hi
HTML, $parsed);
}
+
+ #[Test]
+ public function test_parse_leaves_name_null_by_default(): void
+ {
+ $parsed = $this->markdown->parse('**Hello**');
+
+ $this->assertNull($parsed->name);
+ }
+
+ #[Test]
+ public function test_parse_accepts_an_optional_name(): void
+ {
+ $parsed = $this->markdown->parse('**Hello**', name: 'greeting');
+
+ $this->assertSame('greeting', $parsed->name);
+ $this->assertSame('Hello
', $parsed->html);
+ }
}
diff --git a/tests/MultiMarkdownSplitterTest.php b/tests/MultiMarkdownSplitterTest.php
new file mode 100644
index 0000000..30fe9cd
--- /dev/null
+++ b/tests/MultiMarkdownSplitterTest.php
@@ -0,0 +1,250 @@
+split("\nA\n\n\nB\n");
+
+ $this->assertCount(2, $chunks);
+ $this->assertSame('X', $chunks[0]['name']);
+ $this->assertSame('A', $chunks[0]['content']);
+ $this->assertSame('Y', $chunks[1]['name']);
+ $this->assertSame('B', $chunks[1]['content']);
+ }
+
+ #[Test]
+ public function test_a_trailing_explicitly_named_marker_with_no_content_after_it_is_kept(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split("\nA\n");
+
+ $this->assertCount(2, $chunks);
+ $this->assertSame('Z', $chunks[1]['name']);
+ $this->assertSame('', $chunks[1]['content']);
+ }
+
+ #[Test]
+ public function test_content_that_is_only_a_dangling_auto_marker_produces_no_parts(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split('');
+
+ $this->assertSame([], $chunks);
+ }
+
+ #[Test]
+ public function test_once_any_marker_is_present_no_part_is_ever_left_nameless(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split("X\nY");
+
+ $this->assertCount(2, $chunks);
+ $this->assertSame('chunk-1', $chunks[0]['name']);
+ $this->assertSame('X', $chunks[0]['content']);
+ $this->assertSame('chunk-2', $chunks[1]['name']);
+ $this->assertSame('Y', $chunks[1]['content']);
+ }
+
+ #[Test]
+ public function test_content_without_markers_is_a_single_unnamed_document(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split('Hello world');
+
+ $this->assertCount(1, $chunks);
+ $this->assertNull($chunks[0]['name']);
+ $this->assertSame('Hello world', $chunks[0]['content']);
+ }
+
+ #[Test]
+ public function test_splits_on_next_colon_named_markers(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split(<<
+ Second document
+ MD);
+
+ $this->assertCount(2, $chunks);
+ $this->assertSame('chunk-1', $chunks[0]['name']);
+ $this->assertSame('First document', $chunks[0]['content']);
+ $this->assertSame('services/db-primary.md', $chunks[1]['name']);
+ $this->assertSame('Second document', $chunks[1]['content']);
+ }
+
+ #[Test]
+ public function test_marker_at_the_very_start_produces_no_leading_unnamed_document(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split(<<
+ Only document
+ MD);
+
+ $this->assertCount(1, $chunks);
+ $this->assertSame('readme.md', $chunks[0]['name']);
+ $this->assertSame('Only document', $chunks[0]['content']);
+ }
+
+ #[Test]
+ public function test_bare_next_marker_auto_numbers_from_base_name(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split(<<
+ A
+
+
+ B
+ MD, baseName: 'readme.md');
+
+ $this->assertSame('readme.md-1', $chunks[0]['name']);
+ $this->assertSame('readme.md-2', $chunks[1]['name']);
+ }
+
+ #[Test]
+ public function test_base_name_is_never_treated_as_a_path_with_an_extension(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split("\nA", baseName: 'v1.2');
+
+ $this->assertSame('v1.2-1', $chunks[0]['name']);
+ }
+
+ #[Test]
+ public function test_auto_numbering_counts_every_emitted_chunk_not_just_auto_named_ones(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split("X\nY\nXYZ\n\nZ");
+
+ $this->assertSame(
+ ['chunk-1', 'chunk-2', 'My/NoDoc.txt', 'chunk-4'],
+ array_column($chunks, 'name'),
+ );
+ }
+
+ #[Test]
+ public function test_renaming_one_chunk_does_not_shift_another_chunks_auto_number(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $before = $splitter->split("\nA\n\nB\n\nC");
+ $after = $splitter->split("\nA\n\nB\n\nC");
+
+ $this->assertSame('chunk-3', $before[2]['name']);
+ $this->assertSame('chunk-3', $after[2]['name']);
+ }
+
+ #[Test]
+ public function test_next_colon_wildcard_is_equivalent_to_bare_next(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $bare = $splitter->split("\nA", baseName: 'readme.md');
+ $wildcard = $splitter->split("\nA", baseName: 'readme.md');
+
+ $this->assertSame($bare, $wildcard);
+ }
+
+ #[Test]
+ public function test_next_marker_falls_back_without_base_name(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split("\nA");
+
+ $this->assertSame('chunk-1', $chunks[0]['name']);
+ }
+
+ #[Test]
+ public function test_arbitrary_blank_lines_between_marker_and_content_are_irrelevant(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $tight = $splitter->split("\n---\ntitle: A\n---\nBody");
+ $loose = $splitter->split("\n\n\n---\ntitle: A\n---\nBody");
+
+ $this->assertSame($tight[0]['content'], $loose[0]['content']);
+ $this->assertSame("---\ntitle: A\n---\nBody", $tight[0]['content']);
+ }
+
+ #[Test]
+ public function test_incidental_html_comments_are_never_treated_as_markers(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split(<<
+
+
+ More text.
+ MD);
+
+ $this->assertCount(1, $chunks);
+ $this->assertStringContainsString('', $chunks[0]['content']);
+ $this->assertStringContainsString('', $chunks[0]['content']);
+ }
+
+ #[Test]
+ public function test_indented_marker_is_still_recognized(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split(" \nBody", baseName: 'readme.md');
+
+ $this->assertSame('readme.md-1', $chunks[0]['name']);
+ $this->assertSame('Body', $chunks[0]['content']);
+ }
+
+ #[Test]
+ public function test_keyword_is_overridable(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split(<<
+ A
+
+
+ B
+ MD, keyword: 'nextDoc');
+
+ $this->assertSame('chunk-1', $chunks[0]['name']);
+ $this->assertSame('reports/q1.md', $chunks[1]['name']);
+ }
+
+ #[Test]
+ public function test_default_keyword_is_ignored_once_overridden(): void
+ {
+ $splitter = new MultiMarkdownSplitter();
+
+ $chunks = $splitter->split("\nA", keyword: 'nextDoc');
+
+ $this->assertCount(1, $chunks);
+ $this->assertNull($chunks[0]['name']);
+ $this->assertStringContainsString('', $chunks[0]['content']);
+ }
+}
diff --git a/tests/ParsedMarkdownCollectionTest.php b/tests/ParsedMarkdownCollectionTest.php
new file mode 100644
index 0000000..b90d036
--- /dev/null
+++ b/tests/ParsedMarkdownCollectionTest.php
@@ -0,0 +1,112 @@
+A
', [], 'a'),
+ new ParsedMarkdown('B
', [], 'b'),
+ ]);
+
+ $this->assertCount(2, $collection);
+ $this->assertSame('a', $collection[0]->name);
+ $this->assertSame('B
', $collection['b']->html);
+ }
+
+ #[Test]
+ public function test_parse_many_returns_a_collection_directly(): void
+ {
+ $markdown = new Markdown();
+ $collection = $markdown->parseMany(<<
+ ---
+ title: Waffles
+ ---
+ Mix flour, eggs, and butter.
+ MD);
+
+ $this->assertInstanceOf(ParsedMarkdownCollection::class, $collection);
+ $this->assertSame('Pancakes', $collection[0]->frontmatter['title']);
+ $this->assertSame('Waffles', $collection['recipe-2']->frontmatter['title']);
+ }
+
+ #[Test]
+ public function test_is_countable(): void
+ {
+ $markdown = new Markdown();
+ $collection = $markdown->parseMany("\nA\n\nB");
+
+ $this->assertCount(2, $collection);
+ }
+
+ #[Test]
+ public function test_is_iterable(): void
+ {
+ $markdown = new Markdown();
+ $collection = $markdown->parseMany("\nA\n\nB");
+
+ $names = [];
+
+ foreach ($collection as $chunk) {
+ $names[] = $chunk->name;
+ }
+
+ $this->assertSame(['a', 'b'], $names);
+ }
+
+ #[Test]
+ public function test_unnamed_chunks_are_only_reachable_by_index(): void
+ {
+ $markdown = new Markdown();
+ $collection = $markdown->parseMany('Just prose, no markers at all.');
+
+ $this->assertNull($collection[0]->name);
+ $this->assertFalse(isset($collection['anything']));
+ }
+
+ #[Test]
+ public function test_unknown_offset_returns_null(): void
+ {
+ $markdown = new Markdown();
+ $collection = $markdown->parseMany("\nA");
+
+ $this->assertNull($collection['does-not-exist']);
+ $this->assertNull($collection[99]);
+ }
+
+ #[Test]
+ public function test_setting_an_explicit_offset_is_rejected(): void
+ {
+ $collection = new ParsedMarkdownCollection();
+
+ $this->expectException(InvalidArgumentException::class);
+
+ $collection['some-name'] = new ParsedMarkdown('A
', [], 'a');
+ }
+
+ #[Test]
+ public function test_appending_via_empty_brackets_works(): void
+ {
+ $collection = new ParsedMarkdownCollection();
+ $collection[] = new ParsedMarkdown('A
', [], 'a');
+
+ $this->assertCount(1, $collection);
+ $this->assertSame('a', $collection['a']->name);
+ }
+}
From f4859559ce4f9cc25c2c8274d4bf112423712d8f Mon Sep 17 00:00:00 2001
From: Enrico Kalkbrenner <758813+ek9r@users.noreply.github.com>
Date: Thu, 6 Aug 2026 00:06:06 +0200
Subject: [PATCH 2/2] fix: address mago analyse findings
---
src/MultiMarkdownSplitter.php | 5 +++++
src/ParsedMarkdownCollection.php | 1 +
tests/MarkdownParseManyTest.php | 9 ++++++++-
3 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/src/MultiMarkdownSplitter.php b/src/MultiMarkdownSplitter.php
index 7dfca29..9f39fc9 100644
--- a/src/MultiMarkdownSplitter.php
+++ b/src/MultiMarkdownSplitter.php
@@ -9,11 +9,16 @@ final class MultiMarkdownSplitter
*/
public function split(string $content, ?string $baseName = null, string $keyword = 'next'): array
{
+ $matches = [];
preg_match_all($this->markerPattern($keyword), $content, $matches, PREG_OFFSET_CAPTURE);
$markers = [];
+ // @mago-expect analysis:invalid-destructuring-source
foreach ($matches[0] as $i => [$fullMatch, $offset]) {
+ $fullMatch = (string) $fullMatch;
+ $offset = (int) $offset;
+
$markers[] = [
'rawName' => trim($matches[1][$i][0] ?? ''),
'start' => $offset,
diff --git a/src/ParsedMarkdownCollection.php b/src/ParsedMarkdownCollection.php
index 2e6a114..89b2f75 100644
--- a/src/ParsedMarkdownCollection.php
+++ b/src/ParsedMarkdownCollection.php
@@ -59,6 +59,7 @@ public function offsetGet(mixed $offset): ?ParsedMarkdown
return $this->chunks[$offset] ?? null;
}
+ /** @param int|string|null $offset */
public function offsetSet(mixed $offset, mixed $value): void
{
if ($offset !== null) {
diff --git a/tests/MarkdownParseManyTest.php b/tests/MarkdownParseManyTest.php
index 8db9602..52ec114 100644
--- a/tests/MarkdownParseManyTest.php
+++ b/tests/MarkdownParseManyTest.php
@@ -126,11 +126,15 @@ public function test_known_limitation_marker_inside_a_code_fence_is_still_split(
public function test_the_infrastructure_fixture_parses_into_a_consistent_topology(): void
{
$content = file_get_contents(__DIR__ . '/Fixtures/infrastructure.md');
+ $this->assertIsString($content);
+
$chunks = $this->markdown->parseMany($content);
+ /** @var array> $byName */
$byName = [];
foreach ($chunks as $chunk) {
+ $this->assertNotNull($chunk->name);
$byName[$chunk->name] = $chunk->frontmatter;
}
@@ -139,7 +143,10 @@ public function test_the_infrastructure_fixture_parses_into_a_consistent_topolog
foreach ($byName as $frontmatter) {
foreach (['runs_on', 'depends_on', 'composed_of'] as $relation) {
- foreach ($frontmatter[$relation] ?? [] as $reference) {
+ $references = $frontmatter[$relation] ?? [];
+ $this->assertIsArray($references);
+
+ foreach ($references as $reference) {
$this->assertArrayHasKey($reference, $byName);
}
}