Implement custom parsers/renderers with autowiring for snip content

This commit is contained in:
Tim 2025-04-23 01:06:21 +02:00
parent 5cec259469
commit 943177bc08
15 changed files with 194 additions and 26 deletions

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250422222542 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE snip ADD parser VARCHAR(255) NOT NULL
SQL);
$this->addSql(<<<'SQL'
UPDATE snip SET parser = 'generic'
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE snip DROP parser
SQL);
}
}

View File

@ -8,7 +8,7 @@ use App\Form\SnipType;
use App\Repository\SnipRepository;
use App\Security\Voter\SnipVoter;
use App\Service\SnipContent\SnipContentService;
use App\Service\SnipParser\Pipeline;
use App\Service\SnipParser\ParserFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
@ -42,23 +42,23 @@ class SnipController extends AbstractController
}
#[Route('/single/{snip}', name: '_single')]
public function single(Snip $snip, Pipeline $pl): Response
public function single(Snip $snip, ParserFactory $pf): Response
{
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
return $this->render('snip/single.html.twig', [
'snip' => $snip,
'content' => $pl->parse($this->contentService->getActiveText($snip)),
'content' => $pf->getBySnip($snip)->parseView($this->contentService->getActiveText($snip)),
]);
}
#[Route('/raw/{snip}', name: '_raw')]
public function raw(Snip $snip, Pipeline $pl, Request $request): Response
public function raw(Snip $snip, ParserFactory $pf, Request $request): Response
{
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
$response = new Response(
$pl->clean($this->contentService->getActiveText($snip)),
$pf->getBySnip($snip)->parseRaw($this->contentService->getActiveText($snip)),
Response::HTTP_OK,
['Content-Type' => 'text/html']
);

View File

@ -30,6 +30,9 @@ class Snip
#[ORM\ManyToOne]
private ?SnipContent $activeVersion = null;
#[ORM\Column(length: 255)]
private ?string $parser = null;
public function __construct()
{
$this->snipContents = new ArrayCollection();
@ -115,4 +118,16 @@ class Snip
return $this;
}
public function getParser(): ?string
{
return $this->parser;
}
public function setParser(string $parser): static
{
$this->parser = $parser;
return $this;
}
}

View File

@ -3,17 +3,28 @@
namespace App\Form;
use App\Entity\Snip;
use App\Service\SnipParser\ParserFactory;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SnipType extends AbstractType
{
public function __construct(
private readonly ParserFactory $parserFactory,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
dump(iterator_to_array($this->parserFactory->getChoices()));
$builder
->add('name')
->add('parser', ChoiceType::class, [
'choice_label' => fn(string $parser) => ucfirst($parser),
'choices' => $this->parserFactory->getChoices(),
])
->add('content', TextareaType::class, [
'attr' => ['rows' => 20],
'mapped' => false,

View File

@ -0,0 +1,16 @@
<?php
namespace App\Service\SnipParser;
abstract class AbstractParser implements ParserInterface
{
public static function getName(): string
{
return static::class;
}
public function parseRaw(string $content): string
{
return $content;
}
}

View File

@ -1,22 +1,23 @@
<?php
namespace App\Service\SnipParser;
namespace App\Service\SnipParser\Generic;
use App\Service\SnipParser\Stages\HtmlEscapeStage;
use App\Service\SnipParser\Stages\IncludeReferenceStage;
use App\Service\SnipParser\Stages\UrlReferenceStage;
use App\Service\SnipParser\Stages\ReplaceBlocksStage;
use App\Service\SnipParser\Stages\ReplaceStage;
use App\Service\SnipParser\AbstractParser;
use League\Pipeline\PipelineBuilder;
class Pipeline
class GenericParser extends AbstractParser
{
public static function getName(): string
{
return 'generic';
}
public function __construct(
private readonly UrlReferenceStage $referenceStage,
private readonly IncludeReferenceStage $includeStage,
private readonly UrlReferenceStage $referenceStage,
private readonly IncludeReferenceStage $includeStage,
) {}
public function parse(string $payload): string
public function parseView(string $content): string
{
$builder = new PipelineBuilder();
$pipeline = $builder
@ -29,15 +30,15 @@ class Pipeline
->build()
;
return $pipeline->process($payload);
return $pipeline->process($content);
}
public function clean(string $payload): string
public function parseRaw(string $content): string
{
return str_replace(
['```', '``'],
'',
$payload
$content
);
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\SnipParser\Stages;
namespace App\Service\SnipParser\Generic;
use League\Pipeline\StageInterface;

View File

@ -1,12 +1,11 @@
<?php
namespace App\Service\SnipParser\Stages;
namespace App\Service\SnipParser\Generic;
use App\Repository\SnipContentRepository;
use App\Repository\SnipRepository;
use App\Security\Voter\SnipVoter;
use App\Service\SnipContent\SnipContentService;
use App\Service\SnipParser\Pipeline;
use League\Pipeline\StageInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@ -17,7 +16,7 @@ class IncludeReferenceStage implements StageInterface
#[Autowire(lazy: true)] private readonly Security $security,
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepository,
#[Autowire(lazy: true)] private readonly SnipContentRepository $snipContentRepository,
#[Autowire(lazy: true)] private readonly Pipeline $pipeline,
#[Autowire(lazy: true)] private readonly GenericParser $pipeline,
#[Autowire(lazy: true)] private readonly SnipContentService $snipContentService,
) {}
@ -53,7 +52,7 @@ class IncludeReferenceStage implements StageInterface
return sprintf('<span title="access denied">%s</span>', $matches[0]);
}
return $this->pipeline->parse(
return $this->pipeline->parseView(
$this->snipContentService->rebuildText($content)
);
}, $payload);

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\SnipParser\Stages;
namespace App\Service\SnipParser\Generic;
use InvalidArgumentException;
use League\Pipeline\StageInterface;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\SnipParser\Stages;
namespace App\Service\SnipParser\Generic;
use League\Pipeline\StageInterface;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\SnipParser\Stages;
namespace App\Service\SnipParser\Generic;
use App\Repository\SnipRepository;
use App\Security\Voter\SnipVoter;

View File

@ -0,0 +1,52 @@
<?php
namespace App\Service\SnipParser;
use App\Entity\Snip;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Symfony\Component\DependencyInjection\ServiceLocator;
readonly class ParserFactory
{
public function __construct(
#[AutowireLocator(ParserInterface::class, defaultIndexMethod: 'getName')]
private ServiceLocator $locator
) {}
/**
* @template T of ParserInterface
*
* @param class-string<T> $id
*
* @return T
* @throws ServiceNotFoundException
*/
public function get(string $id): ParserInterface
{
return $this->locator->get($id);
}
public function getBySnip(Snip $snip): ParserInterface
{
$parser = $snip->getParser();
if (null === $parser) {
throw new ServiceNotFoundException(sprintf('Unknown parser for snip "%s"', $snip->getParser()));
}
return $this->get($parser);
}
/**
* @return iterable<string, ParserInterface>
*/
public function getAll(): iterable
{
return $this->locator->getIterator();
}
public function getChoices(): iterable
{
foreach ($this->getAll() as $parser) yield $parser::getName();
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Service\SnipParser;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface ParserInterface
{
public function parseView(string $content): string;
public function parseRaw(string $content): string;
public static function getName(): string;
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Service\SnipParser\Website;
use App\Service\SnipParser\AbstractParser;
class HtmlParser extends AbstractParser
{
public static function getName(): string
{
return 'html';
}
public function parseView(string $content): string
{
return sprintf('<pre><code class="hljs">%s</code></pre>', htmlspecialchars($content));
}
}

View File

@ -35,6 +35,9 @@
<a class="nav-link" href="{{ path('logout') }}">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ path('login') }}">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('register') }}">Register</a>
</li>