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..9f39fc9 --- /dev/null +++ b/src/MultiMarkdownSplitter.php @@ -0,0 +1,75 @@ + + */ + 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, + '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..89b2f75 --- /dev/null +++ b/src/ParsedMarkdownCollection.php @@ -0,0 +1,85 @@ + + * @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; + } + + /** @param int|string|null $offset */ + 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..52ec114 --- /dev/null +++ b/tests/MarkdownParseManyTest.php @@ -0,0 +1,158 @@ +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'); + $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; + } + + $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) { + $references = $frontmatter[$relation] ?? []; + $this->assertIsArray($references); + + foreach ($references 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); + } +}