. Licensed under the GNU Affero General Public License v3.0.
// See the LICENCE file in the repository root for full licence text.
namespace App\Libraries\Markdown;
use App\Libraries\Markdown\CustomContainerInline\Extension as CustomContainerInlineExtension;
use App\Traits\Memoizes;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Extension\DefaultAttributes\DefaultAttributesExtension;
use League\CommonMark\Extension\Footnote\FootnoteExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\Extension\Table\Table;
use League\CommonMark\Extension\Table\TableCell;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\MarkdownConverter;
use League\CommonMark\Node\Block\Paragraph;
use Symfony\Component\Yaml\Exception\ParseException as YamlParseException;
use Symfony\Component\Yaml\Yaml;
class OsuMarkdown
{
use Memoizes;
const VERSION = 14;
const DEFAULT_COMMONMARK_CONFIG = [
'allow_unsafe_links' => false,
'html_input' => 'strip',
'max_nesting_level' => 20,
'renderer' => ['soft_break' => '
'],
];
const DEFAULT_OSU_EXTENSION_CONFIG = [
'block_name' => 'osu-md',
'custom_container_inline' => false,
'fix_wiki_url' => false,
'generate_toc' => false,
'record_first_image' => false,
'relative_url_root' => null,
'style_block_allowed_classes' => null,
'title_from_document' => false,
'wiki_locale' => null,
'with_gallery' => false,
];
// this config is only used in this class
const DEFAULT_OSU_MARKDOWN_CONFIG = [
'block_modifiers' => [],
'enable_autolink' => false,
'enable_footnote' => false,
'parse_yaml_header' => true,
];
const PRESETS = [
'changelog_entry' => [
'commonmark' => [
'html_input' => 'allow',
],
'osu_extension' => [
'block_name' => 'changelog-md',
],
],
'comment' => [
'osu_markdown' => [
'block_modifiers' => ['comment'],
'enable_autolink' => true,
],
],
'contest' => [
'commonmark' => [
'html_input' => 'allow',
],
],
'default' => [],
'group' => [
'osu_markdown' => [
'block_modifiers' => ['group'],
],
],
'news' => [
'commonmark' => [
'html_input' => 'allow',
],
'osu_extension' => [
'attributes_allowed' => ['flag', 'id'],
'custom_container_inline' => true,
'fix_wiki_url' => true,
'generate_toc' => true,
'record_first_image' => true,
],
'osu_markdown' => [
'block_modifiers' => ['news'],
],
],
'store' => [
'commonmark' => [
'html_input' => 'allow',
],
'osu_markdown' => [
'block_modifiers' => ['store'],
],
],
'store-product' => [
'osu_markdown' => [
'block_modifiers' => ['store-product'],
],
],
'store-product-small' => [
'osu_markdown' => [
'block_modifiers' => ['store-product', 'store-product-small'],
],
],
'wiki' => [
'osu_extension' => [
'attributes_allowed' => ['flag', 'id'],
'custom_container_inline' => true,
'fix_wiki_url' => true,
'generate_toc' => true,
'style_block_allowed_classes' => ['infobox'],
'title_from_document' => true,
'with_gallery' => true,
],
'osu_markdown' => [
'block_modifiers' => ['wiki'],
'enable_footnote' => true,
],
],
];
private array $commonmarkConfig;
private array $osuExtensionConfig;
private array $osuMarkdownConfig;
private $document = '';
private $firstImage;
private $header;
private $toc;
private $htmlConverterAndExtension;
private $indexableConverter;
public static function parseYamlHeader($input)
{
$hasMatch = preg_match('#^(?:---\n(?.+?)\n(?:---|\.\.\.)\n)(?.+)$#s', $input, $matches);
if ($hasMatch === 1) {
try {
$header = Yaml::parse($matches['header']);
} catch (YamlParseException $_e) {
// ignores error
}
if (!is_array($header ?? null)) {
$header = null;
}
$document = $matches['document'];
}
return [
'document' => $document ?? $input,
'header' => $header ?? [],
];
}
public function __construct(
$preset,
$commonmarkConfig = [],
$osuExtensionConfig = [],
$osuMarkdownConfig = [],
) {
$presetConfig = static::PRESETS[$preset];
$this->commonmarkConfig = array_merge(
static::DEFAULT_COMMONMARK_CONFIG,
$presetConfig['commonmark'] ?? [],
$commonmarkConfig,
);
$this->osuExtensionConfig = array_merge(
static::DEFAULT_OSU_EXTENSION_CONFIG,
$presetConfig['osu_extension'] ?? [],
$osuExtensionConfig,
);
$this->osuMarkdownConfig = array_merge(
static::DEFAULT_OSU_MARKDOWN_CONFIG,
$presetConfig['osu_markdown'] ?? [],
$osuMarkdownConfig,
);
}
public function html(): string
{
return $this->memoize(__FUNCTION__, function () {
[$converter, $osuExtension] = $this->getHtmlConverterAndExtension();
$blockClass = class_with_modifiers(
$this->osuExtensionConfig['block_name'],
$this->osuMarkdownConfig['block_modifiers'],
);
$converted = $converter->convert($this->document)->getContent();
$processor = $osuExtension->processor;
if ($this->osuExtensionConfig['title_from_document']) {
$this->header['title'] = $processor->title;
}
$this->firstImage = $processor->firstImage;
$this->toc = $processor->toc;
return "{$converted}
";
});
}
public function load($rawInput)
{
$this->resetMemoized();
$rawInput = strip_utf8_bom($rawInput);
if ($this->osuMarkdownConfig['parse_yaml_header']) {
$parsed = static::parseYamlHeader($rawInput);
$this->document = $parsed['document'];
$this->header = $parsed['header'];
} else {
$this->document = $rawInput;
$this->header = [];
}
$this->document = $this->document ?? '';
return $this;
}
public function toArray()
{
$html = $this->html();
return [
'firstImage' => $this->firstImage,
'header' => $this->header,
'output' => $html,
'toc' => $this->toc,
];
}
public function toIndexable(): string
{
return $this->memoize(__FUNCTION__, function () {
return $this->getIndexableConverter()->convert($this->document)->getContent();
});
}
private function getHtmlConverterAndExtension(): array
{
if ($this->htmlConverterAndExtension === null) {
$extraConfig = [
'osu_extension' => $this->osuExtensionConfig,
'default_attributes' => $this->createDefaultAttributesConfig(),
];
if ($this->osuMarkdownConfig['enable_footnote']) {
$extraConfig['footnote'] = $this->createFootnoteConfig();
}
$environment = $this->createEnvironment($extraConfig);
$environment->addExtension(new DefaultAttributesExtension());
$osuExtension = new Osu\Extension();
$environment->addExtension($osuExtension);
$this->htmlConverterAndExtension = [
new MarkdownConverter($environment),
$osuExtension,
];
}
return $this->htmlConverterAndExtension;
}
private function getIndexableConverter(): MarkdownConverter
{
if ($this->indexableConverter === null) {
$environment = $this->createEnvironment(['osu_extension' => $this->osuExtensionConfig]);
$environment->addExtension(new Indexing\Extension());
$this->indexableConverter = new MarkdownConverter($environment);
}
return $this->indexableConverter;
}
private function createEnvironment(array $extraConfig = []): Environment
{
$config = array_merge($this->commonmarkConfig, $extraConfig);
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new TableExtension());
$environment->addExtension(new StrikethroughExtension());
if ($this->osuExtensionConfig['custom_container_inline']) {
$environment->addExtension(new CustomContainerInlineExtension());
}
if ($this->osuExtensionConfig['style_block_allowed_classes'] !== null) {
$environment->addExtension(new StyleBlock\Extension());
}
if ($this->osuMarkdownConfig['enable_footnote']) {
$environment->addExtension(new FootnoteExtension());
}
if ($this->osuMarkdownConfig['enable_autolink']) {
$environment->addExtension(new AutolinkExtension());
}
return $environment;
}
private function createDefaultAttributesConfig(): array
{
$blockClass = $this->osuExtensionConfig['block_name'];
return [
Heading::class => [
'class' => static fn (Heading $node) => class_with_modifiers(
"{$blockClass}__header",
[$node->getLevel()],
),
],
Image::class => [
'class' => "{$blockClass}__image",
],
Link::class => [
'class' => "{$blockClass}__link",
],
ListBlock::class => [
'class' => static fn (ListBlock $node) => class_with_modifiers(
"{$blockClass}__list",
['ordered' => $node->getListData()->type === ListBlock::TYPE_ORDERED]
),
'style' => static function (ListBlock $node) {
if ($node->getListData()->type === ListBlock::TYPE_ORDERED) {
$start = ($node->getListData()->start ?? 1) - 1;
return "--list-start: {$start}";
}
return null;
},
],
ListItem::class => [
'class' => "{$blockClass}__list-item",
],
Paragraph::class => [
'class' => "{$blockClass}__paragraph",
],
StyleBlock\Element::class => [
'class' => static fn (StyleBlock\Element $node) => "{$blockClass}__{$node->getClassName()}",
],
Table::class => [
'class' => "{$blockClass}__table",
],
TableCell::class => [
'class' => static fn (TableCell $node) => class_with_modifiers(
"{$blockClass}__table-data",
[
$node->getAlign() => $node->getAlign() !== null,
'header' => $node->getType() === TableCell::TYPE_HEADER,
]
),
],
];
}
private function createFootnoteConfig()
{
$blockClass = $this->osuExtensionConfig['block_name'];
return [
'backref_class' => "{$blockClass}__link",
'backref_symbol' => '↑',
'container_add_hr' => false,
'container_class' => "{$blockClass}__footnote-container",
'footnote_class' => "{$blockClass}__list-item {$blockClass}__list-item--footnote",
'footnote_id_prefix' => 'fn-',
'ref_class' => "{$blockClass}__link {$blockClass}__link--footnote-ref js-reference-link",
'ref_id_prefix' => 'fnref-',
];
}
}