Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
81 / 81 |
|
100.00% |
22 / 22 |
CRAP | |
100.00% |
1 / 1 |
MarkdownService | |
100.00% |
81 / 81 |
|
100.00% |
22 / 22 |
41 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
parse | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
setupConverter | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
addExtension | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
runPreProcessing | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
runPostProcessing | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
getExtensions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
removeFeature | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
addFeature | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
withPermalinks | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
isDocumentationPage | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
withTableOfContents | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
canEnableTorchlight | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
canEnablePermalinks | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
hasFeature | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
determineIfTorchlightAttributionShouldBeInjected | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
injectTorchlightAttribution | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
configurePermalinksExtension | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
enableAllHtmlElements | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
normalizeIndentationLevel | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getNormalizedLines | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
findLineContentPositions | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 |
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\Framework\Concerns\Internal\SetsUpMarkdownConverter; |
10 | use Hyde\Pages\DocumentationPage; |
11 | use Hyde\Markdown\MarkdownConverter; |
12 | use Hyde\Markdown\Contracts\MarkdownPreProcessorContract as PreProcessor; |
13 | use Hyde\Markdown\Contracts\MarkdownPostProcessorContract as PostProcessor; |
14 | use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension; |
15 | use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension; |
16 | |
17 | use function str_contains; |
18 | use function str_replace; |
19 | use function array_merge; |
20 | use function array_diff; |
21 | use function in_array; |
22 | use function implode; |
23 | use function explode; |
24 | use function substr; |
25 | use function strlen; |
26 | use function filled; |
27 | use function ltrim; |
28 | use 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 | */ |
34 | class 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 | } |