Passed
Push — master ( ad6b2a...b41ca0 )
by Caen
07:45 queued 14s
created

MarkdownService::normalizeIndentationLevel()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 6
c 1
b 0
f 0
nc 3
nop 1
dl 0
loc 13
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Hyde\Framework\Services;
6
7
use Hyde\Facades\Config;
8
use Hyde\Facades\Features;
9
use Hyde\Markdown\Models\MarkdownDocument;
10
use Hyde\Markdown\Processing\HeadingRenderer;
11
use Hyde\Framework\Concerns\Internal\SetsUpMarkdownConverter;
12
use Hyde\Pages\DocumentationPage;
13
use Hyde\Markdown\MarkdownConverter;
14
use Hyde\Markdown\Contracts\MarkdownPreProcessorContract as PreProcessor;
15
use Hyde\Markdown\Contracts\MarkdownPostProcessorContract as PostProcessor;
16
use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
17
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
18
19
use function str_contains;
20
use function str_replace;
21
use function array_merge;
22
use function array_diff;
23
use function in_array;
24
use function implode;
25
use function explode;
26
use function substr;
27
use function strlen;
28
use function filled;
29
use function ltrim;
30
use function trim;
31
32
/**
33
 * Dynamically creates a Markdown converter tailored for the target model and setup,
34
 * then converts the Markdown to HTML using both pre- and post-processors.
35
 */
36
class MarkdownService
37
{
38
    use SetsUpMarkdownConverter;
39
40
    protected string $markdown;
41
    protected ?string $pageClass = null;
42
43
    protected array $config = [];
44
45
    /** @var array<class-string<\League\CommonMark\Extension\ExtensionInterface>> */
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<class-string<\Leag...on\ExtensionInterface>> at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in array<class-string<\League\CommonMark\Extension\ExtensionInterface>>.
Loading history...
46
    protected array $extensions = [];
47
    protected MarkdownConverter $converter;
48
49
    protected string $html;
50
    protected array $features = [];
51
52
    /** @var array<class-string<\Hyde\Markdown\Contracts\MarkdownPreProcessorContract>> */
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<class-string<\Hyde...nPreProcessorContract>> at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in array<class-string<\Hyde\Markdown\Contracts\MarkdownPreProcessorContract>>.
Loading history...
53
    protected array $preprocessors = [];
54
55
    /** @var array<class-string<\Hyde\Markdown\Contracts\MarkdownPostProcessorContract>> */
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<class-string<\Hyde...PostProcessorContract>> at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in array<class-string<\Hyde\Markdown\Contracts\MarkdownPostProcessorContract>>.
Loading history...
56
    protected array $postprocessors = [];
57
58
    /** @var array<string> Tracks all the headings in the document to ensure identifiers are unique */
59
    protected array $headingRegistry = [];
60
61
    public function __construct(string $markdown, ?string $pageClass = null)
62
    {
63
        $this->pageClass = $pageClass;
64
        $this->markdown = $markdown;
65
    }
66
67
    public function parse(): string
68
    {
69
        $this->setupConverter();
70
71
        $this->runPreProcessing();
72
73
        $this->html = (string) $this->converter->convert($this->markdown);
74
75
        $this->runPostProcessing();
76
77
        return $this->html;
78
    }
79
80
    protected function setupConverter(): void
81
    {
82
        $this->enableDynamicExtensions();
83
84
        $this->enableConfigDefinedExtensions();
85
86
        $this->mergeMarkdownConfiguration();
87
88
        $this->converter = new MarkdownConverter($this->config);
89
90
        foreach ($this->extensions as $extension) {
91
            $this->initializeExtension($extension);
92
        }
93
94
        $this->configureCustomHeadingRenderer();
95
96
        $this->registerPreProcessors();
97
        $this->registerPostProcessors();
98
    }
99
100
    public function addExtension(string $extensionClassName): void
101
    {
102
        if (! in_array($extensionClassName, $this->extensions)) {
103
            $this->extensions[] = $extensionClassName;
104
        }
105
    }
106
107
    protected function runPreProcessing(): void
108
    {
109
        /** @var class-string<PreProcessor> $preprocessor */
110
        foreach ($this->preprocessors as $preprocessor) {
111
            $this->markdown = $preprocessor::preprocess($this->markdown);
112
        }
113
    }
114
115
    protected function runPostProcessing(): void
116
    {
117
        if ($this->determineIfTorchlightAttributionShouldBeInjected()) {
118
            $this->html .= $this->injectTorchlightAttribution();
119
        }
120
121
        /** @var class-string<PostProcessor> $postprocessor */
122
        foreach ($this->postprocessors as $postprocessor) {
123
            $this->html = $postprocessor::postprocess($this->html);
124
        }
125
    }
126
127
    public function getExtensions(): array
128
    {
129
        return $this->extensions;
130
    }
131
132
    public function removeFeature(string $feature): static
133
    {
134
        if (in_array($feature, $this->features)) {
135
            $this->features = array_diff($this->features, [$feature]);
136
        }
137
138
        return $this;
139
    }
140
141
    public function addFeature(string $feature): static
142
    {
143
        if (! in_array($feature, $this->features)) {
144
            $this->features[] = $feature;
145
        }
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 hasFeature(string $feature): bool
169
    {
170
        return in_array($feature, $this->features);
171
    }
172
173
    protected function determineIfTorchlightAttributionShouldBeInjected(): bool
174
    {
175
        return ! $this->isDocumentationPage()
176
            && ! (isset($this->pageClass) && $this->pageClass === MarkdownDocument::class)
177
            && Config::getBool('torchlight.attribution.enabled', true)
178
            && str_contains($this->html, 'Syntax highlighted by torchlight.dev');
179
    }
180
181
    protected function injectTorchlightAttribution(): string
182
    {
183
        return '<br>'.$this->converter->convert(Config::getString(
184
            'torchlight.attribution.markdown',
185
            'Syntax highlighted by torchlight.dev'
186
        ));
187
    }
188
189
    protected function enableAllHtmlElements(): void
190
    {
191
        $this->addExtension(DisallowedRawHtmlExtension::class);
192
193
        $this->config = array_merge([
194
            'disallowed_raw_html' => [
195
                'disallowed_tags' => [],
196
            ],
197
        ], $this->config);
198
    }
199
200
    /** Normalize indentation for an un-compiled Markdown string */
201
    public static function normalizeIndentationLevel(string $string): string
202
    {
203
        $lines = self::getNormalizedLines($string);
204
205
        [$startNumber, $indentationLevel] = self::findLineContentPositions($lines);
206
207
        foreach ($lines as $lineNumber => $line) {
208
            if ($lineNumber >= $startNumber) {
209
                $lines[$lineNumber] = substr($line, $indentationLevel);
210
            }
211
        }
212
213
        return implode("\n", $lines);
214
    }
215
216
    /** @return array<int, string> */
217
    protected static function getNormalizedLines(string $string): array
218
    {
219
        return explode("\n", str_replace(["\t", "\r\n"], ['    ', "\n"], $string));
220
    }
221
222
    /**
223
     * Find the indentation level and position of the first line that has content.
224
     *
225
     * @param  array<int, string>  $lines
226
     * @return array<int, int>
227
     */
228
    protected static function findLineContentPositions(array $lines): array
229
    {
230
        foreach ($lines as $lineNumber => $line) {
231
            if (filled(trim($line))) {
232
                $lineLen = strlen($line);
233
                $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
234
235
                if ($lineLen !== $stripLen) {
236
                    return [$lineNumber, $lineLen - $stripLen];
237
                }
238
            }
239
        }
240
241
        return [0, 0];
242
    }
243
244
    protected function configureCustomHeadingRenderer(): void
245
    {
246
        $this->converter->getEnvironment()->addRenderer(Heading::class,
247
            new HeadingRenderer($this->pageClass, $this->headingRegistry)
248
        );
249
    }
250
}
251