Passed
Push — master ( 870e13...7b8ab0 )
by Caen
03:07 queued 13s
created

MarkdownService::normalizeIndentationLevel()   B

Complexity

Conditions 6
Paths 12

Size

Total Lines 35
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

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