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
30 changes: 30 additions & 0 deletions benchmarks/LatexMathProcessorBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace YiiPress\Benchmarks;

use PhpBench\Attributes\Iterations;
use PhpBench\Attributes\Revs;
use PhpBench\Attributes\Warmup;
use YiiPress\Processor\LatexMath\LatexMathProcessor;

final class LatexMathProcessorBench
{
private LatexMathProcessor $processor;
private string $content;

public function __construct()
{
$this->processor = new LatexMathProcessor();
$this->content = str_repeat('<p>Inline <x-equation>x + y</x-equation>.</p>', 100);
}

#[Revs(1_000)]
#[Iterations(3)]
#[Warmup(1)]
public function benchHeadAssetDetection(): void
{
$this->processor->headAssets($this->content);
}
}
2 changes: 2 additions & 0 deletions config/common/di/content-pipeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use YiiPress\Console\InitCommand;
use YiiPress\Console\NewCommand;
use YiiPress\Processor\ContentProcessorPipeline;
use YiiPress\Processor\LatexMath\LatexMathProcessor;
use YiiPress\Processor\Mermaid\MermaidProcessor;
use YiiPress\Processor\OEmbed\OEmbedProcessor;
use YiiPress\Processor\Shortcode\TweetProcessor;
Expand Down Expand Up @@ -40,6 +41,7 @@
Reference::to(TweetProcessor::class),
Reference::to(OEmbedProcessor::class),
Reference::to(MarkdownProcessor::class),
Reference::to(LatexMathProcessor::class),
Reference::to(TagLinkProcessor::class),
Reference::to(MermaidProcessor::class),
Reference::to(SyntaxHighlightProcessor::class),
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ markdown:
- **email_autolinks** — recognize e-mails as auto-links even without `<>` and `mailto:` (default: `true`)
- **www_autolinks** — enable WWW auto-links (even without any scheme prefix, if they begin with 'www.') (default: `true`)
- **collapse_whitespace** — collapse non-trivial whitespace into single space (default: `true`)
- **latex_math** — enable LaTeX math spans `$...$` and `$$...$$` (default: `false`)
- **latex_math** — enable LaTeX math spans `$...$` and `$$...$$` (default: `false`). Pages that contain math automatically include KaTeX CSS/JS and the shipped YiiPress math enhancer script.
- **wikilinks** — enable wiki-style links `[[link]]` (default: `false`)
- **underline** — underscore `_` denotes underline instead of emphasis (default: `false`)
- **no_html_blocks** — disable raw HTML blocks (default: `true`)
Expand Down
16 changes: 16 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ inline-styled highlighted output.

The bundled `minimal` theme adds a client-side **Copy** button to rendered code blocks.

## LaTeX math

When `markdown.latex_math` is enabled, md4c emits math spans as `<x-equation>` elements. YiiPress detects those elements and injects KaTeX rendering assets only on pages that contain math.

Inline math uses `$...$`, and display math uses `$$...$$`:

```markdown
Euler: $e^{i\pi} + 1 = 0$

$$
\int_0^1 x^2\,dx
$$
```

YiiPress ships a small browser enhancer at `assets/plugins/latex-math.js` and loads fixed-version KaTeX CSS/JS from jsDelivr for the renderer itself.

The native extension passes explicit input and output lengths so repeated highlighting calls avoid
extra C-string scans at the PHP/Rust boundary.

Expand Down
1 change: 1 addition & 0 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- [x] Date-based archive pages (yearly, monthly)
- [x] Cross-references / internal linking helpers (shorthand syntax to link between entries, prevents broken links on permalink changes)
- [x] Markdown extensions configuration (enable/disable footnotes, definition lists, strikethrough, tables, etc.)
- [x] KaTeX rendering assets for LaTeX math output

## Priority 2: Documentation

Expand Down
39 changes: 39 additions & 0 deletions src/Processor/LatexMath/LatexMathProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace YiiPress\Processor\LatexMath;

use YiiPress\Content\Model\Entry;
use YiiPress\Processor\AssetProcessorInterface;
use YiiPress\Processor\ContentProcessorInterface;

use function str_contains;

final readonly class LatexMathProcessor implements ContentProcessorInterface, AssetProcessorInterface
{
public function process(string $content, Entry $entry): string
{
return $content;
}

public function headAssets(string $processedContent): string
{
if (!str_contains($processedContent, '<x-equation')) {
return '';
}

return <<<'HTML'
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="assets/plugins/latex-math.js"></script>
HTML;
}

public function assetFiles(): array
{
return [
__DIR__ . '/assets/latex-math.js' => 'assets/plugins/latex-math.js',
];
}
}
30 changes: 30 additions & 0 deletions src/Processor/LatexMath/assets/latex-math.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
(function () {
function renderEquation(element) {
var displayMode = element.getAttribute('type') === 'display';
var replacement = document.createElement(displayMode ? 'div' : 'span');
var source = element.textContent || '';

replacement.className = displayMode ? 'math math-display' : 'math math-inline';

if (!window.katex || typeof window.katex.render !== 'function') {
replacement.textContent = source;
element.replaceWith(replacement);
return;
}

try {
window.katex.render(source, replacement, {
displayMode: displayMode,
throwOnError: false
});
} catch (e) {
replacement.textContent = source;
}

element.replaceWith(replacement);
}

document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('x-equation').forEach(renderEquation);
});
})();
84 changes: 84 additions & 0 deletions tests/Unit/Processor/LatexMathProcessorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace YiiPress\Tests\Unit\Processor;

use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use YiiPress\Content\Model\Entry;
use YiiPress\Processor\LatexMath\LatexMathProcessor;

use function PHPUnit\Framework\assertSame;

final class LatexMathProcessorTest extends TestCase
{
private LatexMathProcessor $processor;

protected function setUp(): void
{
$this->processor = new LatexMathProcessor();
}

public function testProcessLeavesRenderedContentUnchanged(): void
{
$content = '<p>Inline <x-equation>x</x-equation>.</p>';

assertSame($content, $this->processor->process($content, $this->createEntry()));
}

public function testHeadAssetsReturnsKaTexAssetsWhenMathIsPresent(): void
{
$assets = $this->processor->headAssets('<p><x-equation type="display">x</x-equation></p>');

$this->assertStringContainsString('katex.min.css', $assets);
$this->assertStringContainsString('katex.min.js', $assets);
$this->assertStringContainsString('assets/plugins/latex-math.js', $assets);
}

public function testHeadAssetsReturnsEmptyStringWhenMathIsAbsent(): void
{
assertSame('', $this->processor->headAssets('<p>No math.</p>'));
}

public function testAssetFilesReturnsBrowserEnhancer(): void
{
$files = $this->processor->assetFiles();

$this->assertCount(1, $files);
$sourceFile = array_key_first($files);
$this->assertStringEndsWith('/Processor/LatexMath/assets/latex-math.js', $sourceFile);
assertSame('assets/plugins/latex-math.js', $files[$sourceFile]);

$script = file_get_contents($sourceFile);
$this->assertNotFalse($script);
$this->assertStringContainsString("document.querySelectorAll('x-equation')", $script);
$this->assertStringContainsString('window.katex.render', $script);
$this->assertStringContainsString('throwOnError: false', $script);
}

private function createEntry(): Entry
{
return new Entry(
filePath: __FILE__,
collection: 'blog',
slug: 'test',
title: 'Test',
date: new DateTimeImmutable('2024-01-01'),
draft: false,
tags: [],
categories: [],
authors: [],
summary: '',
permalink: '',
layout: '',
theme: '',
weight: 0,
language: '',
redirectTo: '',
extra: [],
bodyOffset: 0,
bodyLength: 0,
);
}
}
Loading