Passed
Push — master ( 6a1d06...cac360 )
by Caen
03:40 queued 12s
created

determineIfTorchlightAttributionShouldBeInjected()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 3
c 0
b 0
f 0
nc 3
nop 0
dl 0
loc 5
rs 10
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 $preprocessor) {
87
            $this->markdown = $preprocessor::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 $postprocessor) {
99
            $this->html = $postprocessor::postprocess($this->html);
100
        }
101
    }
102
103
    public function getExtensions(): array
104
    {
105
        return $this->extensions;
106
    }
107
108
    public function removeFeature(string $feature): static
109
    {
110
        if (in_array($feature, $this->features)) {
111
            $this->features = array_diff($this->features, [$feature]);
112
        }
113
114
        return $this;
115
    }
116
117
    public function addFeature(string $feature): static
118
    {
119
        if (! in_array($feature, $this->features)) {
120
            $this->features[] = $feature;
121
        }
122
123
        return $this;
124
    }
125
126
    public function withPermalinks(): static
127
    {
128
        $this->addFeature('permalinks');
129
130
        return $this;
131
    }
132
133
    public function isDocumentationPage(): bool
134
    {
135
        return isset($this->sourceModel) && $this->sourceModel === DocumentationPage::class;
136
    }
137
138
    public function withTableOfContents(): static
139
    {
140
        $this->addFeature('table-of-contents');
141
142
        return $this;
143
    }
144
145
    public function canEnableTorchlight(): bool
146
    {
147
        return $this->hasFeature('torchlight') ||
148
            Features::hasTorchlight();
149
    }
150
151
    public function canEnablePermalinks(): bool
152
    {
153
        if ($this->hasFeature('permalinks')) {
154
            return true;
155
        }
156
157
        if ($this->isDocumentationPage() && DocumentationPage::hasTableOfContents()) {
158
            return true;
159
        }
160
161
        return false;
162
    }
163
164
    public function hasFeature(string $feature): bool
165
    {
166
        return in_array($feature, $this->features);
167
    }
168
169
    protected function determineIfTorchlightAttributionShouldBeInjected(): bool
170
    {
171
        return ! $this->isDocumentationPage()
172
            && config('torchlight.attribution.enabled', true)
173
            && str_contains($this->html, 'Syntax highlighted by torchlight.dev');
174
    }
175
176
    protected function injectTorchlightAttribution(): string
177
    {
178
        return '<br>'.$this->converter->convert(config(
179
            'torchlight.attribution.markdown',
180
            'Syntax highlighted by torchlight.dev'
181
        ));
182
    }
183
184
    protected function configurePermalinksExtension(): void
185
    {
186
        $this->addExtension(HeadingPermalinkExtension::class);
187
188
        $this->config = array_merge([
189
            'heading_permalink' => [
190
                'id_prefix' => '',
191
                'fragment_prefix' => '',
192
                'symbol' => '#',
193
                'insert' => 'after',
194
                'min_heading_level' => 2,
195
            ],
196
        ], $this->config);
197
    }
198
199
    protected function enableAllHtmlElements(): void
200
    {
201
        $this->addExtension(DisallowedRawHtmlExtension::class);
202
203
        $this->config = array_merge([
204
            'disallowed_raw_html' => [
205
                'disallowed_tags' => [],
206
            ],
207
        ], $this->config);
208
    }
209
210
    /**
211
     * Normalize indentation for an un-compiled Markdown string.
212
     */
213
    public static function normalizeIndentationLevel(string $string): string
214
    {
215
        $lines = self::getNormalizedLines($string);
216
217
        [$startNumber, $indentationLevel] = self::findLineContentPositions($lines);
218
219
        foreach ($lines as $lineNumber => $line) {
220
            if ($lineNumber >= $startNumber) {
221
                $lines[$lineNumber] = substr($line, $indentationLevel);
222
            }
223
        }
224
225
        return implode("\n", $lines);
226
    }
227
228
    protected static function getNormalizedLines(string $string): array
229
    {
230
        return explode("\n", str_replace(["\t", "\r\n"], ['    ', "\n"], $string));
231
    }
232
233
    /** @return int[]  Find the indentation level and position of the first line that has content */
234
    protected static function findLineContentPositions(array $lines): array
235
    {
236
        foreach ($lines as $lineNumber => $line) {
237
            if (filled(trim($line))) {
238
                $lineLen = strlen($line);
239
                $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
240
241
                if ($lineLen !== $stripLen) {
242
                    return [$lineNumber, $lineLen - $stripLen];
243
                }
244
            }
245
        }
246
247
        return [0, 0];
248
    }
249
}
250