Skip to content

Commit 910c427

Browse files
author
olaru
committed
Add tests
1 parent 1a2f0a7 commit 910c427

11 files changed

Lines changed: 312 additions & 0 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Light\App\Factory;
6+
7+
use Psr\Container\ContainerInterface;
8+
use Twig\Extra\Markdown\MarkdownExtension;
9+
10+
class MarkdownExtensionFactory
11+
{
12+
public function __invoke(ContainerInterface $container): MarkdownExtension
13+
{
14+
return new MarkdownExtension();
15+
}
16+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Light\App\Factory;
6+
7+
use League\CommonMark\GithubFlavoredMarkdownConverter;
8+
use Psr\Container\ContainerInterface;
9+
use Twig\Extra\Markdown\LeagueMarkdown;
10+
use Twig\Extra\Markdown\MarkdownRuntime;
11+
use Twig\RuntimeLoader\FactoryRuntimeLoader;
12+
use Twig\RuntimeLoader\RuntimeLoaderInterface;
13+
14+
class MarkdownRuntimeLoaderFactory
15+
{
16+
public function __invoke(ContainerInterface $container): RuntimeLoaderInterface
17+
{
18+
return new FactoryRuntimeLoader([
19+
MarkdownRuntime::class => static fn (): MarkdownRuntime => new MarkdownRuntime(
20+
new LeagueMarkdown(new GithubFlavoredMarkdownConverter())
21+
),
22+
]);
23+
}
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Light\App\Service;
6+
7+
use function preg_replace;
8+
use function trim;
9+
10+
/**
11+
* Strips the leading `# Title` heading (already rendered separately from the article's DB
12+
* title) and the `## TL;DR` section (an LLM-summary artifact, not meant for human display) from
13+
* a Markdown article body.
14+
*/
15+
final class ArticleBodyCleaner
16+
{
17+
public static function clean(string $body): string
18+
{
19+
$body = (string) preg_replace('/\A#[ \t][^\n]*\n/', '', $body, 1);
20+
$body = (string) preg_replace('/^## TL;DR\s*$.*?(?=^## |\z)/ms', '', $body, 1);
21+
22+
return trim($body);
23+
}
24+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Light\App\Service;
6+
7+
use function preg_match;
8+
use function preg_match_all;
9+
use function preg_replace;
10+
use function trim;
11+
12+
use const PREG_SET_ORDER;
13+
14+
/**
15+
* Pulls the trailing `## FAQ` section (a `**Q: ...**` / `A: ...` pair per question) out of a
16+
* Markdown article body, so it can be rendered as an accordion and fed into JSON-LD separately
17+
* from the rest of the body.
18+
*/
19+
final class FaqExtractor
20+
{
21+
/**
22+
* @return array{faq: list<array{question: string, answer: string}>, body: string}
23+
*/
24+
public static function extract(string $body): array
25+
{
26+
if (preg_match('/^## FAQ\s*$(.*)\z/ms', $body, $section, offset: 0) !== 1) {
27+
return ['faq' => [], 'body' => $body];
28+
}
29+
30+
$faq = [];
31+
preg_match_all(
32+
'/\*\*Q:\s*(.+?)\*\*\s*\n\s*A:\s*(.+?)(?=\n\s*\n|\z)/s',
33+
$section[1],
34+
$matches,
35+
PREG_SET_ORDER
36+
);
37+
foreach ($matches as $match) {
38+
$faq[] = [
39+
'question' => trim($match[1]),
40+
'answer' => trim(preg_replace('/\s+/', ' ', $match[2]) ?? ''),
41+
];
42+
}
43+
44+
$body = trim((string) preg_replace('/^## FAQ\s*$.*\z/ms', '', $body));
45+
46+
return ['faq' => $faq, 'body' => $body];
47+
}
48+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Light\App\Service;
6+
7+
use Symfony\Component\Yaml\Yaml;
8+
9+
use function preg_match;
10+
use function trim;
11+
12+
/**
13+
* Splits a Markdown file's leading `---` YAML front matter block from its body.
14+
*/
15+
final class FrontMatter
16+
{
17+
/**
18+
* @return array{meta: array<string, mixed>, body: string}
19+
*/
20+
public static function parse(string $raw): array
21+
{
22+
if (preg_match('/\A---\r?\n(.*?)\r?\n---\r?\n?(.*)\z/s', $raw, $matches) !== 1) {
23+
return ['meta' => [], 'body' => trim($raw)];
24+
}
25+
26+
/** @var array<string, mixed> $meta */
27+
$meta = Yaml::parse($matches[1]) ?? [];
28+
29+
return ['meta' => $meta, 'body' => trim($matches[2])];
30+
}
31+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{% extends '@layout/blog-post.html.twig' %}
2+
3+
{% block body %}
4+
{{ content|markdown_to_html }}
5+
6+
{% if faq is not empty %}
7+
<h2 id="user-content-faq">Frequently Asked Questions</h2>
8+
9+
<div class="faq-list">
10+
{% for item in faq %}
11+
<details class="acc"{% if loop.first %} open{% endif %}>
12+
<summary>{{ item.question }} <span class="chev">+</span></summary>
13+
<div class="acc-body">
14+
<p class="mb-0">{{ item.answer }}</p>
15+
</div>
16+
</details>
17+
{% endfor %}
18+
</div>
19+
{% endif %}
20+
{% endblock %}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LightTest\Unit\App\Factory;
6+
7+
use Light\App\Factory\MarkdownExtensionFactory;
8+
use LightTest\Unit\UnitTest;
9+
use PHPUnit\Framework\MockObject\Exception;
10+
use Psr\Container\ContainerInterface;
11+
use Twig\TwigFilter;
12+
13+
use function array_map;
14+
15+
class MarkdownExtensionFactoryTest extends UnitTest
16+
{
17+
/**
18+
* @throws Exception
19+
*/
20+
public function testInvokeReturnsAMarkdownExtensionRegisteringTheMarkdownFilter(): void
21+
{
22+
$extension = (new MarkdownExtensionFactory())($this->createStub(ContainerInterface::class));
23+
24+
$filterNames = array_map(
25+
static fn (TwigFilter $filter): string => $filter->getName(),
26+
$extension->getFilters()
27+
);
28+
29+
$this->assertContains('markdown_to_html', $filterNames);
30+
}
31+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LightTest\Unit\App\Factory;
6+
7+
use Light\App\Factory\MarkdownRuntimeLoaderFactory;
8+
use LightTest\Unit\UnitTest;
9+
use PHPUnit\Framework\MockObject\Exception;
10+
use Psr\Container\ContainerInterface;
11+
use Twig\Extra\Markdown\MarkdownRuntime;
12+
13+
class MarkdownRuntimeLoaderFactoryTest extends UnitTest
14+
{
15+
/**
16+
* @throws Exception
17+
*/
18+
public function testInvokeReturnsALoaderThatResolvesTheMarkdownRuntime(): void
19+
{
20+
$loader = (new MarkdownRuntimeLoaderFactory())($this->createStub(ContainerInterface::class));
21+
22+
$this->assertInstanceOf(MarkdownRuntime::class, $loader->load(MarkdownRuntime::class));
23+
$this->assertNull($loader->load('SomeUnknownRuntime'));
24+
}
25+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LightTest\Unit\App\Service;
6+
7+
use Light\App\Service\ArticleBodyCleaner;
8+
use LightTest\Unit\UnitTest;
9+
10+
class ArticleBodyCleanerTest extends UnitTest
11+
{
12+
public function testCleanStripsTheLeadingTitleAndTlDrSection(): void
13+
{
14+
$body = "# A Title\n\n## TL;DR\n\nA short summary.\n\n## First Section\n\nThe real content.";
15+
16+
$this->assertSame("## First Section\n\nThe real content.", ArticleBodyCleaner::clean($body));
17+
}
18+
19+
public function testCleanOnlyStripsTheTitleWhenThereIsNoTlDrSection(): void
20+
{
21+
$body = "# A Title\n\n## First Section\n\nThe real content.";
22+
23+
$this->assertSame("## First Section\n\nThe real content.", ArticleBodyCleaner::clean($body));
24+
}
25+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LightTest\Unit\App\Service;
6+
7+
use Light\App\Service\FaqExtractor;
8+
use LightTest\Unit\UnitTest;
9+
10+
class FaqExtractorTest extends UnitTest
11+
{
12+
public function testExtractPullsTheQuestionAnswerPairsOutOfTheFaqSection(): void
13+
{
14+
$body = "Some intro text.\n\n## FAQ\n\n"
15+
. "**Q: What is it?**\nA: It's an example.\n\n"
16+
. "**Q: Why does it matter?**\nA: Because tests need coverage.";
17+
18+
$result = FaqExtractor::extract($body);
19+
20+
$this->assertSame('Some intro text.', $result['body']);
21+
$this->assertSame(
22+
[
23+
['question' => 'What is it?', 'answer' => "It's an example."],
24+
['question' => 'Why does it matter?', 'answer' => 'Because tests need coverage.'],
25+
],
26+
$result['faq']
27+
);
28+
}
29+
30+
public function testExtractReturnsAnEmptyFaqWhenThereIsNoFaqSection(): void
31+
{
32+
$body = "Some intro text.\n\nMore content.";
33+
34+
$result = FaqExtractor::extract($body);
35+
36+
$this->assertSame([], $result['faq']);
37+
$this->assertSame($body, $result['body']);
38+
}
39+
}

0 commit comments

Comments
 (0)