Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
93 / 93 |
|
100.00% |
8 / 8 |
CRAP | |
100.00% |
1 / 1 |
MarkdownFactory | |
100.00% |
93 / 93 |
|
100.00% |
8 / 8 |
25 | |
100.00% |
1 / 1 |
__invoke | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
getEnvironment | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
getBaseConfig | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
getExtensionClass | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
getConfigForExtension | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
getConfigForCoreExtension | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
sanitizeConfig | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
9 | |||
createConfig | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | /** |
4 | * Class MarkdownFactory |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Moravian Library 2020. |
9 | * |
10 | * This program is free software; you can redistribute it and/or modify |
11 | * it under the terms of the GNU General Public License version 2, |
12 | * as published by the Free Software Foundation. |
13 | * |
14 | * This program is distributed in the hope that it will be useful, |
15 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
17 | * GNU General Public License for more details. |
18 | * |
19 | * You should have received a copy of the GNU General Public License |
20 | * along with this program; if not, write to the Free Software |
21 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
22 | * |
23 | * @category VuFind |
24 | * @package VuFind\Service |
25 | * @author Josef Moravec <moravec@mzk.cz> |
26 | * @author Aleksi Peebles <aleksi.peebles@helsinki.fi> |
27 | * @license https://opensource.org/licenses/gpl-2.0.php GNU General Public License |
28 | * @link https://knihovny.cz Main Page |
29 | */ |
30 | |
31 | namespace VuFind\Service; |
32 | |
33 | use Laminas\ServiceManager\Exception\ServiceNotCreatedException; |
34 | use Laminas\ServiceManager\Exception\ServiceNotFoundException; |
35 | use Laminas\ServiceManager\Factory\FactoryInterface; |
36 | use League\CommonMark\Environment\Environment; |
37 | use League\CommonMark\Environment\EnvironmentBuilderInterface; |
38 | use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; |
39 | use League\CommonMark\MarkdownConverter; |
40 | use Psr\Container\ContainerExceptionInterface as ContainerException; |
41 | use Psr\Container\ContainerInterface; |
42 | |
43 | use function count; |
44 | |
45 | /** |
46 | * VuFind Markdown Service factory. |
47 | * |
48 | * @category VuFind |
49 | * @package Service |
50 | * @author Josef Moravec <moravec@mzk.cz> |
51 | * @author Aleksi Peebles <aleksi.peebles@helsinki.fi> |
52 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
53 | * @link https://vufind.org/wiki/development Wiki |
54 | */ |
55 | class MarkdownFactory implements FactoryInterface |
56 | { |
57 | /** |
58 | * Array of config keys for extensions classes |
59 | * |
60 | * @var string[] |
61 | */ |
62 | protected static $configKeys = [ |
63 | 'CommonMarkCore' => 'commonmark', |
64 | 'DefaultAttributes' => 'default_attributes', |
65 | 'DisallowedRawHtml' => 'disallowed_raw_html', |
66 | 'ExternalLink' => 'external_link', |
67 | 'Footnote' => 'footnote', |
68 | 'HeadingPermalink' => 'heading_permalink', |
69 | 'Mention' => 'mentions', |
70 | 'SmartPunct' => 'smartpunct', |
71 | 'Table' => 'table', |
72 | 'TableOfContents' => 'table_of_contents', |
73 | ]; |
74 | |
75 | /** |
76 | * Default set of extensions |
77 | * |
78 | * @var string[] |
79 | */ |
80 | protected static $defaultExtensions = [ |
81 | 'Autolink', 'DisallowedRawHtml', 'Strikethrough', 'Table', 'TaskList', |
82 | ]; |
83 | |
84 | /** |
85 | * Markdown processor configuration |
86 | * |
87 | * @var array |
88 | */ |
89 | protected $config; |
90 | |
91 | /** |
92 | * Enabled extensions |
93 | * |
94 | * @var array |
95 | */ |
96 | protected $extensions; |
97 | |
98 | /** |
99 | * Dependency injection container |
100 | * |
101 | * @var ContainerInterface |
102 | */ |
103 | protected $container; |
104 | |
105 | /** |
106 | * Create an object |
107 | * |
108 | * @param ContainerInterface $container Service manager |
109 | * @param string $requestedName Service being created |
110 | * @param null|array $options Extra options (optional) |
111 | * |
112 | * @return object |
113 | * |
114 | * @throws ServiceNotFoundException if unable to resolve the service. |
115 | * @throws ServiceNotCreatedException if an exception is raised when |
116 | * creating a service. |
117 | * @throws ContainerException&\Throwable if any other error occurs |
118 | */ |
119 | public function __invoke( |
120 | ContainerInterface $container, |
121 | $requestedName, |
122 | array $options = null |
123 | ) { |
124 | $this->config = $container->get(\VuFind\Config\PluginManager::class) |
125 | ->get('markdown')->toArray(); |
126 | $this->extensions = isset($this->config['Markdown']['extensions']) |
127 | ? array_map( |
128 | 'trim', |
129 | explode(',', $this->config['Markdown']['extensions']) |
130 | ) |
131 | : self::$defaultExtensions; |
132 | $this->extensions = array_filter($this->extensions); |
133 | $this->container = $container; |
134 | |
135 | return new MarkdownConverter($this->getEnvironment()); |
136 | } |
137 | |
138 | /** |
139 | * Get Markdown environment. |
140 | * |
141 | * @return EnvironmentBuilderInterface |
142 | */ |
143 | protected function getEnvironment(): EnvironmentBuilderInterface |
144 | { |
145 | $environment = new Environment($this->createConfig()); |
146 | $environment->addExtension(new CommonMarkCoreExtension()); |
147 | foreach ($this->extensions as $extension) { |
148 | $extensionClass = $this->getExtensionClass($extension); |
149 | // For case, somebody needs to create extension using custom factory, we |
150 | // try to get the object from DI container if possible |
151 | $extensionObject = $this->container->has($extensionClass) |
152 | ? $this->container->get($extensionClass) |
153 | : new $extensionClass(); |
154 | $environment->addExtension($extensionObject); |
155 | } |
156 | return $environment; |
157 | } |
158 | |
159 | /** |
160 | * Get Markdown base config. |
161 | * |
162 | * @return array |
163 | */ |
164 | protected function getBaseConfig(): array |
165 | { |
166 | $mainConfig = $this->config['Markdown'] ?? []; |
167 | return [ |
168 | 'html_input' => $mainConfig['html_input'] ?? 'strip', |
169 | 'allow_unsafe_links' |
170 | => (bool)($mainConfig['allow_unsafe_links'] ?? false), |
171 | 'max_nesting_level' |
172 | => (int)($mainConfig['max_nesting_level'] ?? \PHP_INT_MAX), |
173 | 'renderer' => [ |
174 | 'block_separator' |
175 | => $mainConfig['renderer']['block_separator'] ?? "\n", |
176 | 'inner_separator' |
177 | => $mainConfig['renderer']['inner_separator'] ?? "\n", |
178 | 'soft_break' => $mainConfig['renderer']['soft_break'] ?? "\n", |
179 | ], |
180 | ]; |
181 | } |
182 | |
183 | /** |
184 | * Get full class name for given extension |
185 | * |
186 | * @param string $extension Extension name |
187 | * |
188 | * @return string |
189 | */ |
190 | protected function getExtensionClass(string $extension): string |
191 | { |
192 | $extensionClass = (str_contains($extension, '\\')) |
193 | ? $extension |
194 | : sprintf( |
195 | 'League\CommonMark\Extension\%s\%sExtension', |
196 | $extension, |
197 | $extension |
198 | ); |
199 | if (!class_exists($extensionClass)) { |
200 | throw new ServiceNotCreatedException( |
201 | sprintf( |
202 | "Could not create markdown service. Extension '%s' not found", |
203 | $extension |
204 | ) |
205 | ); |
206 | } |
207 | return $extensionClass; |
208 | } |
209 | |
210 | /** |
211 | * Get config for given extension |
212 | * |
213 | * @param string $extension Extension name |
214 | * |
215 | * @return array |
216 | */ |
217 | protected function getConfigForExtension(string $extension): array |
218 | { |
219 | if (isset($this->config[$extension])) { |
220 | $configKey = self::$configKeys[$extension] |
221 | ?? $this->config[$extension]['config_key'] |
222 | ?? ''; |
223 | unset($this->config[$extension]['config_key']); |
224 | return $configKey !== '' |
225 | ? [ $configKey => $this->config[$extension] ] |
226 | : []; |
227 | } |
228 | return []; |
229 | } |
230 | |
231 | /** |
232 | * Get config for core extension |
233 | * |
234 | * @return array |
235 | */ |
236 | protected function getConfigForCoreExtension(): array |
237 | { |
238 | $config = $this->getConfigForExtension('CommonMarkCore'); |
239 | $configOptions = [ |
240 | 'enable_em', |
241 | 'enable_strong', |
242 | 'use_asterisk', |
243 | 'use_underscore', |
244 | ]; |
245 | foreach ($configOptions as $option) { |
246 | $config['commonmark'][$option] |
247 | = (bool)($config['commonmark'][$option] |
248 | ?? $this->config['Markdown'][$option] |
249 | ?? true); |
250 | unset($this->config['Markdown'][$option]); |
251 | } |
252 | $markdown = $this->config['Markdown'] ?? []; |
253 | $config['commonmark']['unordered_list_markers'] |
254 | ??= $markdown['unordered_list_markers'] |
255 | ?? ['-', '*', '+']; |
256 | unset($this->config['Markdown']['unordered_list_markers']); |
257 | |
258 | return $config; |
259 | } |
260 | |
261 | /** |
262 | * Sanitize some config options |
263 | * |
264 | * @param array $config Full config |
265 | * |
266 | * @return array |
267 | */ |
268 | protected function sanitizeConfig(array $config): array |
269 | { |
270 | $boolSettingKeys = [ |
271 | ['external_link', 'open_in_new_window'], |
272 | ['footnote', 'container_add_hr'], |
273 | ['heading_permalink', 'aria_hidden'], |
274 | ['heading_permalink', 'apply_id_to_heading'], |
275 | ]; |
276 | foreach ($boolSettingKeys as $key) { |
277 | if (isset($config[$key[0]][$key[1]])) { |
278 | $config[$key[0]][$key[1]] = (bool)$config[$key[0]][$key[1]]; |
279 | } |
280 | } |
281 | if (isset($config['table']['wrap']['enabled'])) { |
282 | $config['table']['wrap']['enabled'] |
283 | = (bool)$config['table']['wrap']['enabled']; |
284 | } |
285 | $intSettingKeys = [ |
286 | ['table_of_contents', 'min_heading_level'], |
287 | ['table_of_contents', 'max_heading_level'], |
288 | ['heading_permalink', 'min_heading_level'], |
289 | ['heading_permalink', 'max_heading_level'], |
290 | ]; |
291 | foreach ($intSettingKeys as $key) { |
292 | if (isset($config[$key[0]][$key[1]])) { |
293 | $config[$key[0]][$key[1]] = (int)$config[$key[0]][$key[1]]; |
294 | } |
295 | } |
296 | |
297 | $parseAttributes = function (string $attributes): array { |
298 | $attributes = array_map( |
299 | 'trim', |
300 | explode(',', $attributes) |
301 | ); |
302 | $attributesArray = []; |
303 | foreach ($attributes as $attribute) { |
304 | $parts = array_map('trim', explode(':', $attribute)); |
305 | if (2 === count($parts)) { |
306 | $attributesArray[$parts[0]] = $parts[1]; |
307 | } |
308 | } |
309 | return $attributesArray; |
310 | }; |
311 | $attributesConfigKeys = [ |
312 | ['wrap', 'attributes'], |
313 | ['alignment_attributes', 'left'], |
314 | ['alignment_attributes', 'center'], |
315 | ['alignment_attributes', 'right'], |
316 | ]; |
317 | foreach ($attributesConfigKeys as $keys) { |
318 | $config['table'][$keys[0]][$keys[1]] = $parseAttributes($config['table'][$keys[0]][$keys[1]] ?? ''); |
319 | } |
320 | |
321 | return $config; |
322 | } |
323 | |
324 | /** |
325 | * Create full config for markdown converter |
326 | * |
327 | * @return array |
328 | */ |
329 | protected function createConfig(): array |
330 | { |
331 | $baseConfig = $this->getBaseConfig(); |
332 | $coreConfig = $this->getConfigForCoreExtension(); |
333 | $config = array_merge($baseConfig, $coreConfig); |
334 | foreach ($this->extensions as $extension) { |
335 | $extConfig = $this->getConfigForExtension($extension); |
336 | $config = array_merge($config, $extConfig); |
337 | } |
338 | return $this->sanitizeConfig($config); |
339 | } |
340 | } |