thephpleague / commonmark

Highly-extensible PHP Markdown parser which fully supports the CommonMark and GFM specs.
https://commonmark.thephpleague.com
BSD 3-Clause "New" or "Revised" License
2.71k stars 190 forks source link

Markdown to Shell #1029

Open Renji-FR opened 1 month ago

Renji-FR commented 1 month ago

Description

For CLI application using PHP CLI, the goal is to render Markdown to Shell output

We must use the Shell statements like this one:

echo "\033[92mMy text\033[0m"

I already have code that work for my project/application.

I used this schema in order to custom some renderer:

            $builder->addSchema('commonmark', Expect::structure([
                'use_asterisk' => Expect::bool(true),
                'use_underscore' => Expect::bool(true),
                'enable_strong' => Expect::bool(true),
                'enable_em' => Expect::bool(true),
                'unordered_list_markers' => Expect::listOf('string')->min(1)->default(['*', '+', '-'])->mergeDefaults(false),

                'renderer' => Expect::structure([
                    'heading_line_starter' => Expect::string("➤ "),
                    'quoted_line_starter' => Expect::string("│ "),
                    'ordered_list_bullet' => Expect::string("%d. "),
                    'unordered_list_bullet' => Expect::string("• "),
                ]),
            ]));

Example

Let me know if you want to implement this feature in your project.

I would like to create a pull request / merge request if this feature is useful for your project.

These are the classes that I created to implement this feature:

I already created my unit tests too.

Did this project help you today? Did it make you happy in any way?

Yes, I created all classes allowing to render Markdown to Shell output.

Renji-FR commented 1 month ago

Just for the issue, this is my class ShellElement

<?php
    declare(strict_types=1);

    namespace Core\Markdown\Util;

    final class ShellElement implements \Stringable
    {
        /**
         * @var array<string, string|bool>
         */
        private array $attributes = [];

        /**
         * @var \Stringable|\Stringable[]|string
         */
        private mixed $contents;

        /**
         * @psalm-readonly
         */
        private bool $resetClosing;

        /**
         * @param array<string, string|string[]|bool>   $attributes   Array of attributes (values should be unescaped)
         * @param \Stringable|\Stringable[]|string|null $contents     Inner contents, pre-escaped if needed
         * @param bool                                  $resetClosing Append reset statement at the end
         */
        public function __construct(
            array $attributes = [],
            mixed $contents = '',
            bool $resetClosing = false
        ) {
            $this->resetClosing = $resetClosing;

            foreach ($attributes as $name => $value) {
                $this->setAttribute($name, $value);
            }

            $this->setContents($contents ?? '');
        }

        /**
         * @return array<string, string|bool>
         *
         * @psalm-immutable
         */
        public function getAllAttributes(): array
        {
            return $this->attributes;
        }

        /**
         * @return string|bool|null
         *
         * @psalm-immutable
         */
        public function getAttribute(string $key): mixed
        {
            return $this->attributes[$key] ?? null;
        }

        /**
         * @param string|string[]|bool $value
         */
        public function setAttribute(string $key, mixed $value = true): self
        {
            if (\is_array($value)) {
                $this->attributes[$key] = \array_unique($value);
            } else {
                $this->attributes[$key] = $value;
            }

            return $this;
        }

        /**
         * @return \Stringable|\Stringable[]|string
         *
         * @psalm-immutable
         */
        public function getContents(bool $asString = true): mixed
        {
            if (!$asString) {
                return $this->contents;
            }

            return $this->_getContentsAsString();
        }

        /**
         * Sets the inner contents of the tag (must be pre-escaped if needed)
         *
         * @param \Stringable|\Stringable[]|string $contents
         */
        public function setContents(mixed $contents): self
        {
            $this->contents = $contents ?? ''; // @phpstan-ignore-line

            return $this;
        }

        /**
         * @psalm-immutable
         */
        private function _getContentsAsString(): string
        {
            if (\is_string($this->contents)) {
                return $this->contents;
            }
            elseif (\is_array($this->contents)) {
                return \implode('', $this->contents);
            }
            else {
                return (string) $this->contents;
            }
        }

        /**
         * @psalm-immutable
         */
        public function __toString(): string
        {
            if ($this->contents === '') {
                return '';
            }

            $result = '';

            $styles = $this->attributes['styles'] ?? [];

            if (is_array($styles) && ($style = implode(';', $styles)) !== '') {
                $result .= "\033[" . $style . "m";
            }

            $result .= $this->_getContentsAsString();

            if ($this->resetClosing) {
                $result .= "\033[0m";
            }
            else
            {
                $resetStyles = [];

                foreach ($styles as $style)
                {
                    if ($style >= 1 && $style <= 9) {
                        $resetStyles[] = $style + 20;
                    }
                    elseif ($style >= 30 && $style < 39 || $style >= 90 && $style <= 99) {
                        $resetStyles[] = 39;
                    }
                    elseif ($style >= 40 && $style < 49 || $style >= 100 && $style <= 109) {
                        $resetStyles[] = 49;
                    }
                }

                if (($resetStyle = implode(';', $resetStyles)) !== '') {
                    $result .= "\033[" . $resetStyle . "m";
                }
            }

            return $result;
        }
    }
Renji-FR commented 1 month ago

Another solution is to use the Termwind project:

<?php
    declare(strict_types=1);

    namespace PhpCliShell\Core\Markdown;

    use League\CommonMark\CommonMarkConverter;
    use League\CommonMark\Environment\Environment;
    use League\CommonMark\Exception\CommonMarkException;

    use Termwind\HtmlRenderer;

    /**
     * Converts CommonMark-compatible Markdown to Shell output.
     */
    final class HtmlConverter
    {
        /**
         * @psalm-readonly
         */
        private CommonMarkConverter $converter;

        /**
         * Create a new Markdown converter pre-configured for CommonMark
         *
         * @param array<string, mixed> $config
         */
        public function __construct(array $config = [])
        {
            $this->converter = new CommonMarkConverter($config);
        }

        public function getEnvironment(): Environment
        {
            return $this->converter->getEnvironment();
        }

        /**
         * Converts Markdown to Shell output.
         *
         * @param string $input Markdown input
         * @return string Rendered Shell output
         */
        public function convert(string $input): string
        {
            $htmlContent = $this->converter->convert($input);
            return (new HtmlRenderer)->parse((string) $htmlContent)->toString();
        }

        /**
         * Converts CommonMark to Shell output.
         *
         * @see HtmlConverter::convert()
         *
         * @throws CommonMarkException
         */
        public function __invoke(string $markdown): string
        {
            return $this->convert($markdown);
        }
    }