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

View File

@ -30,6 +30,9 @@ class Snip
#[ORM\ManyToOne] #[ORM\ManyToOne]
private ?SnipContent $activeVersion = null; private ?SnipContent $activeVersion = null;
#[ORM\Column(length: 255)]
private ?string $parser = null;
public function __construct() public function __construct()
{ {
$this->snipContents = new ArrayCollection(); $this->snipContents = new ArrayCollection();
@ -115,4 +118,16 @@ class Snip
return $this; 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; namespace App\Form;
use App\Entity\Snip; use App\Entity\Snip;
use App\Service\SnipParser\ParserFactory;
use Symfony\Component\Form\AbstractType; 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\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
class SnipType extends AbstractType class SnipType extends AbstractType
{ {
public function __construct(
private readonly ParserFactory $parserFactory,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
dump(iterator_to_array($this->parserFactory->getChoices()));
$builder $builder
->add('name') ->add('name')
->add('parser', ChoiceType::class, [
'choice_label' => fn(string $parser) => ucfirst($parser),
'choices' => $this->parserFactory->getChoices(),
])
->add('content', TextareaType::class, [ ->add('content', TextareaType::class, [
'attr' => ['rows' => 20], 'attr' => ['rows' => 20],
'mapped' => false, '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 <?php
namespace App\Service\SnipParser; namespace App\Service\SnipParser\Generic;
use App\Service\SnipParser\Stages\HtmlEscapeStage; use App\Service\SnipParser\AbstractParser;
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 League\Pipeline\PipelineBuilder; use League\Pipeline\PipelineBuilder;
class Pipeline class GenericParser extends AbstractParser
{ {
public static function getName(): string
{
return 'generic';
}
public function __construct( public function __construct(
private readonly UrlReferenceStage $referenceStage, private readonly UrlReferenceStage $referenceStage,
private readonly IncludeReferenceStage $includeStage, private readonly IncludeReferenceStage $includeStage,
) {} ) {}
public function parse(string $payload): string public function parseView(string $content): string
{ {
$builder = new PipelineBuilder(); $builder = new PipelineBuilder();
$pipeline = $builder $pipeline = $builder
@ -29,15 +30,15 @@ class Pipeline
->build() ->build()
; ;
return $pipeline->process($payload); return $pipeline->process($content);
} }
public function clean(string $payload): string public function parseRaw(string $content): string
{ {
return str_replace( return str_replace(
['```', '``'], ['```', '``'],
'', '',
$payload $content
); );
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Service\SnipParser\Stages; namespace App\Service\SnipParser\Generic;
use App\Repository\SnipRepository; use App\Repository\SnipRepository;
use App\Security\Voter\SnipVoter; 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> <a class="nav-link" href="{{ path('logout') }}">Logout</a>
</li> </li>
{% else %} {% else %}
<li class="nav-item">
<a class="nav-link" href="{{ path('login') }}">Login</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ path('register') }}">Register</a> <a class="nav-link" href="{{ path('register') }}">Register</a>
</li> </li>