Skip to content
Merged
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
20 changes: 13 additions & 7 deletions docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,21 @@ The library follows a classic parser/renderer architecture:
```
Input (Djot String)
BlockParser
AST (Document)
HtmlRenderer
Output (HTML String)
default HTML facade ── accepted core document ──→ borrowed HTML layout
└── fallback ──→ BlockParser → AST (Document) → HtmlRenderer
Output (HTML String)
```

`DjotConverter::convert()` has a conservative allocation-light route for
default HTML conversion of ASCII documents up to 64 KiB. It borrows source
slices and constructs no public AST nodes. The route is whole-document and
fail-closed: lazy/ambiguous headings, richer block or inline syntax, custom
configuration, profiles, extensions, listeners, transformers, and alternate
renderers remain on the authoritative AST pipeline. `parse()` always returns
the full public AST.

## Components

### BlockParser
Expand Down
25 changes: 25 additions & 0 deletions docs/reference/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@ Performance benchmarks for djot-php compared to other implementations.

## Quick Reference

Default source-to-HTML conversion now includes a conservative borrowed-source
route for common documents up to 64 KiB. On PHP 8.5.9 with CLI OPcache, a
57,410-byte heading/paragraph/core-inline fixture measured 6.5 ms median versus
49.5 ms through an explicitly configured owned-AST converter (7.7x faster),
with byte-identical output. Absolute timings remain machine-dependent.

The cross-language runners now generate the same paragraph-safe medium fixture
in every runtime. A 20-iteration/5-warmup run over its 51,179 bytes produced:

| Djot implementation | Median | Throughput | Relative to PHP |
|---------------------|--------|------------|-----------------|
| Rust jotdown 0.7 | 1.20 ms | 40.5 MB/s | 6.22x faster |
| **PHP djot-php** | **7.46 ms** | **6.5 MB/s** | baseline |
| JavaScript @djot/djot 0.3 | 10.99 ms | 4.4 MB/s | 1.47x slower |
| Go godjot 1.0.6 | 17.47 ms | 2.9 MB/s | 2.34x slower |

Environment: PHP 8.5.9, Node.js 22.22.2, Rust 1.97.1, and Go 1.22.2.
Run `node tests/benchmark/compare-languages.mjs --djot-only
--iterations=20 --warmup=5` from the repository root to reproduce it.

Unsupported or ambiguous input automatically falls back to the complete parser
and renderer. Calling `parse()`, selecting another renderer, or configuring
profiles, safety, extensions, listeners, source lines, or output behavior also
keeps the authoritative path.

| Document Size | PHP Full | Throughput |
|---------------|----------|------------|
| 1 KB | 0.51 ms | ~2.1 MB/s |
Expand Down
56 changes: 52 additions & 4 deletions src/DjotConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Djot\Filter\ProfileFilter;
use Djot\Node\Document;
use Djot\Parser\BlockParser;
use Djot\Performance\BorrowedHtmlLayout;
use Djot\Renderer\AnsiRenderer;
use Djot\Renderer\HeadingIdTracker;
use Djot\Renderer\HtmlRenderer;
Expand All @@ -32,6 +33,10 @@
*/
class DjotConverter
{
private bool $borrowedHtmlEligible;

private ?BorrowedHtmlLayout $borrowedHtmlLayout = null;

protected BlockParser $parser;

protected RendererInterface $renderer;
Expand Down Expand Up @@ -151,6 +156,22 @@ public function __construct(
bool $sourceLines = false,
bool $sections = true,
) {
$borrowedHtmlEligible = !$xhtml
&& !$warnings
&& !$strict
&& $safeMode === null
&& $profile === null
&& !$significantNewlines
&& $softBreakMode === null
&& !$roundTripMode
&& $parser === null
&& $renderer === null
&& !$nestedBlocksInLists
&& !$blocksInterruptParagraphs
&& !$nestedListsWithoutBlankLine
&& !$sourceLines
&& $sections;

$this->collectWarnings = $warnings;
$this->strictMode = $strict;

Expand Down Expand Up @@ -191,6 +212,8 @@ public function __construct(
if ($profile !== null) {
$this->profileFilter = new ProfileFilter();
}

$this->borrowedHtmlEligible = $borrowedHtmlEligible;
}

/**
Expand Down Expand Up @@ -347,6 +370,8 @@ public static function withNestedListsWithoutBlankLine(
*/
public function setSafeMode(SafeMode|bool|null $safeMode): self
{
$this->borrowedHtmlEligible = false;

if (!$this->renderer instanceof HtmlRenderer) {
return $this;
}
Expand All @@ -369,6 +394,8 @@ public function setSafeMode(SafeMode|bool|null $safeMode): self
*/
public function setProfile(?Profile $profile): self
{
$this->borrowedHtmlEligible = false;

$this->profile = $profile;
if ($profile !== null && $this->profileFilter === null) {
$this->profileFilter = new ProfileFilter();
Expand All @@ -390,9 +417,16 @@ public function getProfile(): ?Profile
*/
public function convert(string $djot): string
{
// Check max length before parsing
$this->enforceProfileMaxLength($djot);

if ($this->borrowedHtmlEligible) {
$this->borrowedHtmlLayout ??= new BorrowedHtmlLayout();
$rendered = $this->borrowedHtmlLayout->render($djot);
if ($rendered !== null) {
return $rendered['html'];
}
}

return $this->render($this->parse($djot));
}

Expand All @@ -412,9 +446,7 @@ public function convertFile(string $path): string
throw new RuntimeException("Failed to read file: {$path}");
}

$this->enforceProfileMaxLength($content);

return $this->render($this->parse($content));
return $this->convert($content);
}

/**
Expand Down Expand Up @@ -520,6 +552,8 @@ public function render(Document $document): string
*/
public function on(string $event, Closure $listener): self
{
$this->borrowedHtmlEligible = false;

if ($this->renderer instanceof HtmlRenderer) {
$this->renderer->on($event, $listener);
}
Expand All @@ -532,6 +566,8 @@ public function on(string $event, Closure $listener): self
*/
public function off(?string $event = null): self
{
$this->borrowedHtmlEligible = false;

if ($this->renderer instanceof HtmlRenderer) {
$this->renderer->off($event);
}
Expand All @@ -544,6 +580,8 @@ public function off(?string $event = null): self
*/
public function getRenderer(): RendererInterface
{
$this->borrowedHtmlEligible = false;

return $this->renderer;
}

Expand All @@ -554,6 +592,8 @@ public function getRenderer(): RendererInterface
*/
public function getHtmlRenderer(): HtmlRenderer
{
$this->borrowedHtmlEligible = false;

if (!$this->renderer instanceof HtmlRenderer) {
throw new LogicException('getHtmlRenderer() is only available when using HtmlRenderer');
}
Expand All @@ -568,6 +608,8 @@ public function getHtmlRenderer(): HtmlRenderer
*/
public function getHeadingIdTracker(): HeadingIdTracker
{
$this->borrowedHtmlEligible = false;

if (!$this->renderer instanceof HtmlRenderer) {
throw new LogicException('getHeadingIdTracker() is only supported with HtmlRenderer');
}
Expand All @@ -580,6 +622,8 @@ public function getHeadingIdTracker(): HeadingIdTracker
*/
public function getParser(): BlockParser
{
$this->borrowedHtmlEligible = false;

return $this->parser;
}

Expand All @@ -598,6 +642,8 @@ public function getParser(): BlockParser
*/
public function addExtension(ExtensionInterface $extension): self
{
$this->borrowedHtmlEligible = false;

$this->assertCompatibleExtension($extension);
$registeredExtension = $extension instanceof BeforeRenderExtensionInterface ? clone $extension : $extension;
$this->extensions[] = $registeredExtension;
Expand Down Expand Up @@ -645,6 +691,8 @@ public function getExtensions(): array
*/
public function addOutputTransformer(Closure $transformer): self
{
$this->borrowedHtmlEligible = false;

$this->outputTransformers[] = $transformer;

return $this;
Expand Down
Loading
Loading