Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
22 / 22
CRAP
100.00% covered (success)
100.00%
1 / 1
MarkdownService
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
22 / 22
41
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setupConverter
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 addExtension
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 runPreProcessing
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 runPostProcessing
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getExtensions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeFeature
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addFeature
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 withPermalinks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isDocumentationPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 withTableOfContents
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 canEnableTorchlight
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 canEnablePermalinks
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 hasFeature
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 determineIfTorchlightAttributionShouldBeInjected
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 injectTorchlightAttribution
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 configurePermalinksExtension
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 enableAllHtmlElements
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 normalizeIndentationLevel
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getNormalizedLines
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findLineContentPositions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Hyde\Framework\Services;
6
7use Hyde\Facades\Config;
8use Hyde\Facades\Features;
9use Hyde\Framework\Concerns\Internal\SetsUpMarkdownConverter;
10use Hyde\Pages\DocumentationPage;
11use Hyde\Markdown\MarkdownConverter;
12use Hyde\Markdown\Contracts\MarkdownPreProcessorContract as PreProcessor;
13use Hyde\Markdown\Contracts\MarkdownPostProcessorContract as PostProcessor;
14use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
15use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
16
17use function str_contains;
18use function str_replace;
19use function array_merge;
20use function array_diff;
21use function in_array;
22use function implode;
23use function explode;
24use function substr;
25use function strlen;
26use function filled;
27use function ltrim;
28use function trim;
29
30/**
31 * Dynamically creates a Markdown converter tailored for the target model and setup,
32 * then converts the Markdown to HTML using both pre- and post-processors.
33 */
34class MarkdownService
35{
36    use SetsUpMarkdownConverter;
37
38    protected string $markdown;
39    protected ?string $pageClass = null;
40
41    protected array $config = [];
42
43    /** @var array<class-string<\League\CommonMark\Extension\ExtensionInterface>> */
44    protected array $extensions = [];
45    protected MarkdownConverter $converter;
46
47    protected string $html;
48    protected array $features = [];
49
50    /** @var array<class-string<\Hyde\Markdown\Contracts\MarkdownPreProcessorContract>> */
51    protected array $preprocessors = [];
52
53    /** @var array<class-string<\Hyde\Markdown\Contracts\MarkdownPostProcessorContract>> */
54    protected array $postprocessors = [];
55
56    public function __construct(string $markdown, ?string $pageClass = null)
57    {
58        $this->pageClass = $pageClass;
59        $this->markdown = $markdown;
60    }
61
62    public function parse(): string
63    {
64        $this->setupConverter();
65
66        $this->runPreProcessing();
67
68        $this->html = (string) $this->converter->convert($this->markdown);
69
70        $this->runPostProcessing();
71
72        return $this->html;
73    }
74
75    protected function setupConverter(): void
76    {
77        $this->enableDynamicExtensions();
78
79        $this->enableConfigDefinedExtensions();
80
81        $this->mergeMarkdownConfiguration();
82
83        $this->converter = new MarkdownConverter($this->config);
84
85        foreach ($this->extensions as $extension) {
86            $this->initializeExtension($extension);
87        }
88
89        $this->registerPreProcessors();
90        $this->registerPostProcessors();
91    }
92
93    public function addExtension(string $extensionClassName): void
94    {
95        if (! in_array($extensionClassName, $this->extensions)) {
96            $this->extensions[] = $extensionClassName;
97        }
98    }
99
100    protected function runPreProcessing(): void
101    {
102        /** @var class-string<PreProcessor> $preprocessor */
103        foreach ($this->preprocessors as $preprocessor) {
104            $this->markdown = $preprocessor::preprocess($this->markdown);
105        }
106    }
107
108    protected function runPostProcessing(): void
109    {
110        if ($this->determineIfTorchlightAttributionShouldBeInjected()) {
111            $this->html .= $this->injectTorchlightAttribution();
112        }
113
114        /** @var class-string<PostProcessor> $postprocessor */
115        foreach ($this->postprocessors as $postprocessor) {
116            $this->html = $postprocessor::postprocess($this->html);
117        }
118    }
119
120    public function getExtensions(): array
121    {
122        return $this->extensions;
123    }
124
125    public function removeFeature(string $feature): static
126    {
127        if (in_array($feature, $this->features)) {
128            $this->features = array_diff($this->features, [$feature]);
129        }
130
131        return $this;
132    }
133
134    public function addFeature(string $feature): static
135    {
136        if (! in_array($feature, $this->features)) {
137            $this->features[] = $feature;
138        }
139
140        return $this;
141    }
142
143    public function withPermalinks(): static
144    {
145        $this->addFeature('permalinks');
146
147        return $this;
148    }
149
150    public function isDocumentationPage(): bool
151    {
152        return isset($this->pageClass) && $this->pageClass === DocumentationPage::class;
153    }
154
155    public function withTableOfContents(): static
156    {
157        $this->addFeature('table-of-contents');
158
159        return $this;
160    }
161
162    public function canEnableTorchlight(): bool
163    {
164        return $this->hasFeature('torchlight') ||
165            Features::hasTorchlight();
166    }
167
168    public function canEnablePermalinks(): bool
169    {
170        if ($this->hasFeature('permalinks')) {
171            return true;
172        }
173
174        if ($this->isDocumentationPage() && DocumentationPage::hasTableOfContents()) {
175            return true;
176        }
177
178        return false;
179    }
180
181    public function hasFeature(string $feature): bool
182    {
183        return in_array($feature, $this->features);
184    }
185
186    protected function determineIfTorchlightAttributionShouldBeInjected(): bool
187    {
188        return ! $this->isDocumentationPage()
189            && Config::getBool('torchlight.attribution.enabled', true)
190            && str_contains($this->html, 'Syntax highlighted by torchlight.dev');
191    }
192
193    protected function injectTorchlightAttribution(): string
194    {
195        return '<br>'.$this->converter->convert(Config::getString(
196            'torchlight.attribution.markdown',
197            'Syntax highlighted by torchlight.dev'
198        ));
199    }
200
201    protected function configurePermalinksExtension(): void
202    {
203        $this->addExtension(HeadingPermalinkExtension::class);
204
205        $this->config = array_merge([
206            'heading_permalink' => [
207                'id_prefix' => '',
208                'fragment_prefix' => '',
209                'symbol' => '',
210                'insert' => 'after',
211                'min_heading_level' => 2,
212                'aria_hidden' => false,
213            ],
214        ], $this->config);
215    }
216
217    protected function enableAllHtmlElements(): void
218    {
219        $this->addExtension(DisallowedRawHtmlExtension::class);
220
221        $this->config = array_merge([
222            'disallowed_raw_html' => [
223                'disallowed_tags' => [],
224            ],
225        ], $this->config);
226    }
227
228    /**
229     * Normalize indentation for an un-compiled Markdown string.
230     */
231    public static function normalizeIndentationLevel(string $string): string
232    {
233        $lines = self::getNormalizedLines($string);
234
235        [$startNumber, $indentationLevel] = self::findLineContentPositions($lines);
236
237        foreach ($lines as $lineNumber => $line) {
238            if ($lineNumber >= $startNumber) {
239                $lines[$lineNumber] = substr($line, $indentationLevel);
240            }
241        }
242
243        return implode("\n", $lines);
244    }
245
246    /** @return array<int, string> */
247    protected static function getNormalizedLines(string $string): array
248    {
249        return explode("\n", str_replace(["\t", "\r\n"], ['    ', "\n"], $string));
250    }
251
252    /**
253     * Find the indentation level and position of the first line that has content.
254     *
255     * @param  array<int, string>  $lines
256     * @return array<int, int>
257     */
258    protected static function findLineContentPositions(array $lines): array
259    {
260        foreach ($lines as $lineNumber => $line) {
261            if (filled(trim($line))) {
262                $lineLen = strlen($line);
263                $stripLen = strlen(ltrim($line)); // Length of the line without indentation lets us know its indentation level, and thus how much to strip from each line
264
265                if ($lineLen !== $stripLen) {
266                    return [$lineNumber, $lineLen - $stripLen];
267                }
268            }
269        }
270
271        return [0, 0];
272    }
273}