Initial baseline: LaraBlog core with plugin commerce surface.
Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Blog;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMXPath;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
use Mews\Purifier\Facades\Purifier;
|
||||
|
||||
class ContentRenderer
|
||||
{
|
||||
public function __construct(
|
||||
protected AttachEmbed $attachEmbed,
|
||||
) {}
|
||||
|
||||
public function toHtml(string $content, string $format, ?int $articleId = null): string
|
||||
{
|
||||
return $this->render($content, $format, $articleId)['html'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{html: string, toc: list<array{level: int, id: string, text: string}>}
|
||||
*/
|
||||
public function render(string $content, string $format, ?int $articleId = null): array
|
||||
{
|
||||
$format = ContentFormat::normalize($format);
|
||||
|
||||
if ($format === ContentFormat::MARKDOWN) {
|
||||
$content = $this->attachEmbed->legacyToMarkdownReference($content, $articleId);
|
||||
$content = $this->attachEmbed->expandMarkdownAttachProtocol($content);
|
||||
$html = $this->markdownToHtml($content);
|
||||
} else {
|
||||
$html = $content;
|
||||
}
|
||||
|
||||
$html = $this->attachEmbed->hydrateHtml($html, $articleId);
|
||||
$html = Purifier::clean($html, 'article');
|
||||
|
||||
return $this->applyTocAnchors($html);
|
||||
}
|
||||
|
||||
public function markdownToHtml(string $markdown): string
|
||||
{
|
||||
$environment = new Environment([
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
'heading_permalink' => [
|
||||
'html_class' => 'heading-permalink',
|
||||
'id_prefix' => '',
|
||||
'fragment_prefix' => '',
|
||||
'insert' => 'none',
|
||||
'apply_id_to_heading' => true,
|
||||
'heading_class' => '',
|
||||
],
|
||||
]);
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$environment->addExtension(new GithubFlavoredMarkdownExtension);
|
||||
$environment->addExtension(new HeadingPermalinkExtension);
|
||||
|
||||
return (string) (new MarkdownConverter($environment))->convert($markdown);
|
||||
}
|
||||
|
||||
public function htmlToMarkdown(string $html): string
|
||||
{
|
||||
$converter = new HtmlConverter([
|
||||
'strip_tags' => true,
|
||||
'hard_break' => true,
|
||||
]);
|
||||
|
||||
return trim($converter->convert($html));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert sablog HTML article body into Markdown storage form.
|
||||
* Protects [attach=id] / src="[attach=id]" with placeholders during conversion.
|
||||
*/
|
||||
public function convertImportedHtmlToMarkdown(string $html, ?int $articleId = null): string
|
||||
{
|
||||
[$protected, $map] = $this->attachEmbed->protectLegacyTokensForHtmlConversion($html, $articleId);
|
||||
$markdown = $this->htmlToMarkdown($protected);
|
||||
|
||||
return $this->attachEmbed->restoreProtectedTokensToMarkdown($markdown, $map);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{html: string, toc: list<array{level: int, id: string, text: string}>}
|
||||
*/
|
||||
protected function applyTocAnchors(string $html): array
|
||||
{
|
||||
if (trim($html) === '') {
|
||||
return ['html' => '', 'toc' => []];
|
||||
}
|
||||
|
||||
$document = new DOMDocument;
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$document->loadHTML(
|
||||
'<?xml encoding="UTF-8"><div id="larablog-toc-root">'.$html.'</div>',
|
||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
|
||||
);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[@id="larablog-toc-root"]//*[self::h2 or self::h3 or self::h4]');
|
||||
if ($nodes === false) {
|
||||
return ['html' => $html, 'toc' => []];
|
||||
}
|
||||
|
||||
$toc = [];
|
||||
$usedIds = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
if (! $node instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = trim(preg_replace('/\s+/u', ' ', $node->textContent ?? '') ?? '');
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$level = (int) substr(strtolower($node->tagName), 1);
|
||||
$id = $node->getAttribute('id');
|
||||
if ($id === '') {
|
||||
$id = $this->slugifyHeading($text);
|
||||
}
|
||||
|
||||
$base = $id;
|
||||
$i = 2;
|
||||
while (isset($usedIds[$id])) {
|
||||
$id = $base.'-'.$i;
|
||||
$i++;
|
||||
}
|
||||
$usedIds[$id] = true;
|
||||
$node->setAttribute('id', $id);
|
||||
|
||||
$toc[] = [
|
||||
'level' => $level,
|
||||
'id' => $id,
|
||||
'text' => $text,
|
||||
];
|
||||
}
|
||||
|
||||
$root = $document->getElementById('larablog-toc-root');
|
||||
$out = $html;
|
||||
if ($root instanceof DOMElement) {
|
||||
$out = '';
|
||||
foreach ($root->childNodes as $child) {
|
||||
$out .= $document->saveHTML($child);
|
||||
}
|
||||
}
|
||||
|
||||
return ['html' => $out, 'toc' => $toc];
|
||||
}
|
||||
|
||||
protected function slugifyHeading(string $text): string
|
||||
{
|
||||
$slug = strtolower(trim($text));
|
||||
$slug = preg_replace('/[^\p{L}\p{N}\-_]+/u', '-', $slug) ?? '';
|
||||
$slug = trim($slug, '-');
|
||||
|
||||
return $slug !== '' ? $slug : 'section';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user