render($content, $format, $articleId)['html']; } /** * @return array{html: string, toc: list} */ 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} */ protected function applyTocAnchors(string $html): array { if (trim($html) === '') { return ['html' => '', 'toc' => []]; } $document = new DOMDocument; $previous = libxml_use_internal_errors(true); $document->loadHTML( '
'.$html.'
', 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'; } }