Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
93 / 93
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
MarkdownFactory
100.00% covered (success)
100.00%
93 / 93
100.00% covered (success)
100.00%
8 / 8
25
100.00% covered (success)
100.00%
1 / 1
 __invoke
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getEnvironment
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getBaseConfig
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 getExtensionClass
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 getConfigForExtension
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getConfigForCoreExtension
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 sanitizeConfig
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 createConfig
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
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
31namespace VuFind\Service;
32
33use Laminas\ServiceManager\Exception\ServiceNotCreatedException;
34use Laminas\ServiceManager\Exception\ServiceNotFoundException;
35use Laminas\ServiceManager\Factory\FactoryInterface;
36use League\CommonMark\Environment\Environment;
37use League\CommonMark\Environment\EnvironmentBuilderInterface;
38use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
39use League\CommonMark\MarkdownConverter;
40use Psr\Container\ContainerExceptionInterface as ContainerException;
41use Psr\Container\ContainerInterface;
42
43use 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 */
55class 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}