Skip to content
Open
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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<!-- next -->` marker. The simplest case doesn't even need frontmatter:

```md
First part.
<!-- next -->
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.

<!-- next: recipe-2 -->
---
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.
Expand Down
20 changes: 18 additions & 2 deletions src/Markdown.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
75 changes: 75 additions & 0 deletions src/MultiMarkdownSplitter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

namespace Tempest\Markdown;

final class MultiMarkdownSplitter
{
/**
* @return list<array{name: ?string, content: string}>
*/
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]*<!--\\s*{$escaped}(?:\\s*:\\s*(.*?))?\\s*-->[ \\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}";
}
}
1 change: 1 addition & 0 deletions src/ParsedMarkdown.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
public function __construct(
public string $html,
public array $frontmatter,
public ?string $name = null,
) {}

public function __toString(): string
Expand Down
85 changes: 85 additions & 0 deletions src/ParsedMarkdownCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

namespace Tempest\Markdown;

use ArrayAccess;
use ArrayIterator;
use Countable;
use InvalidArgumentException;
use IteratorAggregate;
use Traversable;

/**
* @implements IteratorAggregate<int, \Tempest\Markdown\ParsedMarkdown>
* @implements ArrayAccess<int|string, \Tempest\Markdown\ParsedMarkdown>
*/
final class ParsedMarkdownCollection implements IteratorAggregate, ArrayAccess, Countable
{
/** @var list<ParsedMarkdown> */
private array $chunks = [];

/** @var array<string, int> */
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);
}
}
66 changes: 66 additions & 0 deletions tests/Fixtures/infrastructure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!-- next: web-1 -->
---
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

<!-- next: db-1 -->
---
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

<!-- next: web-service -->
---
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.

<!-- next: db-service -->
---
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.

<!-- next: storefront -->
---
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.
Loading