Repository: thephpleague/commonmark (php)

Issue:
Table extension: Add (optional?) container element to handle horizontal overflow
### Description

I'm using the Tables extension to add tables to markdown. On small screens the tables often extend outside of the viewport, breaking the page layout.

This is because elements with `display: table` do not respond to `width` and `max-width` the same way block elements do. There's no easy CSS way to constrain the table to its container. In the past I've handled it like so:

```html
<div style="overflow-x: auto; max-width: 100%">
  <table><!-- table innards... --> </table>
</div>
```

It would be nice if there was a way to configure the tables extension to wrap all tables in an element (`div`?) with a configurable class (e.g. `table-container`). This would make it easy to use the table extension without breaking responsive layouts. 

In the meantime it seems like the only workaround is to extend the Table Renderer with a custom render function. (Which is not the end of the world, by any means, but it would be nice if this was a built-in option.)

I'm currently pretty busy at work, but if this is a feature you're open to I could try to find time to make a PR in the future.

### Example

Take the following markdown:

```md
|Heading|Heading|
|--------|--------|
|Content|Content|
```

It currently outputs something like this:

```html
<table><!-- table innards... --></table>
```

It would be nice if it output this instead:

```html
<div class="table-container">
  <table><!-- table innards... --></table>
</div>
```

To prevent making this a breaking change, it would probably be best to make this an optional configuration setting for the tables extension.

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

This project is sooooooo good. I'm working on a project that requires some pretty complex custom markdown parsing, and the extension structure makes this really easy to set up. (I was originally planning to use a lot of regex, but I'm glad I found this project instead 😅 )

Thank you so much for your work on this project! It works great, is a breeze to build off of, and has saved me a ton of time!

Pull request description:
Decorate table output
Closes #780

Patch:
diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php
index 1b01d666..2b1c7450 100644
--- a/.phpstorm.meta.php
+++ b/.phpstorm.meta.php
@@ -78,6 +78,11 @@ namespace PHPSTORM_META
         'slug_normalizer/instance',
         'slug_normalizer/max_length',
         'slug_normalizer/unique',
+        'table',
+        'table/wrap',
+        'table/wrap/attributes',
+        'table/wrap/enabled',
+        'table/wrap/tag',
         'table_of_contents',
         'table_of_contents/html_class',
         'table_of_contents/max_heading_level',
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43a2ff67..a040aba7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,12 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi
 
  - Added new `ConverterInterface`
  - Added new `MarkdownToXmlConverter` class
+ - Added new `HtmlDecorator` class which can wrap existing renderers with additional HTML tags
+ - Added new `table/wrap` config to apply an optional wrapping/container element around a table (#780)
+
+### Changed
+
+ - `HtmlElement` contents can now consist of any `Stringable`, not just `HtmlElement` and `string`
 
 ### Deprecated
 
diff --git a/docs/2.2/extensions/tables.md b/docs/2.2/extensions/tables.md
index c79b683f..7e0205e8 100644
--- a/docs/2.2/extensions/tables.md
+++ b/docs/2.2/extensions/tables.md
@@ -31,7 +31,15 @@ use League\CommonMark\Extension\Table\TableExtension;
 use League\CommonMark\MarkdownConverter;
 
 // Define your configuration, if needed
-$config = [];
+$config = [
+    'table' => [
+        'wrap' => [
+            'enabled' => false,
+            'tag' => 'div',
+            'attributes' => [],
+        ],
+    ],
+];
 
 // Configure the Environment with all the CommonMark parsers/renderers
 $environment = new Environment($config);
@@ -89,6 +97,30 @@ Result:
 | cell 2.1 | cell 2.2 | cell 2.3 |
 ```
 
+## Configuration
+
+### Wrapping Container
+
+You can "wrap" the table with a container element by configuring the following options:
+
+- `enabled`: (`boolean`) Whether to wrap the table with a container element. Defaults to `false`.
+- `tag`: (`string`) The tag name of the container element. Defaults to `div`.
+- `attributes`: (`array`) An array of attributes to apply to the container element. Defaults to `[]`.
+
+For example, to wrap all tables within a `<div class="table-responsive">` container element:
+
+```php
+$config = [
+    'table' => [
+        'wrap' => [
+            'enabled' => true,
+            'tag' => 'div',
+            'attributes' => ['class' => 'table-responsive'],
+        ],
+    ],
+];
+```
+
 ## Credits
 
 The Table functionality was originally built by [Martin Hasoň](https://github.com/hason) and [Webuni s.r.o.](https://www.webuni.cz) before it was merged into the core parser.
diff --git a/src/Extension/Table/TableExtension.php b/src/Extension/Table/TableExtension.php
index 5cd17a94..2e4f2324 100644
--- a/src/Extension/Table/TableExtension.php
+++ b/src/Extension/Table/TableExtension.php
@@ -16,16 +16,35 @@ declare(strict_types=1);
 namespace League\CommonMark\Extension\Table;
 
 use League\CommonMark\Environment\EnvironmentBuilderInterface;
-use League\CommonMark\Extension\ExtensionInterface;
+use League\CommonMark\Extension\ConfigurableExtensionInterface;
+use League\CommonMark\Renderer\HtmlDecorator;
+use League\Config\ConfigurationBuilderInterface;
+use Nette\Schema\Expect;
 
-final class TableExtension implements ExtensionInterface
+final class TableExtension implements ConfigurableExtensionInterface
 {
+    public function configureSchema(ConfigurationBuilderInterface $builder): void
+    {
+        $builder->addSchema('table', Expect::structure([
+            'wrap' => Expect::structure([
+                'enabled' => Expect::bool()->default(false),
+                'tag' => Expect::string()->default('div'),
+                'attributes' => Expect::arrayOf(Expect::string()),
+            ]),
+        ]));
+    }
+
     public function register(EnvironmentBuilderInterface $environment): void
     {
+        $tableRenderer = new TableRenderer();
+        if ($environment->getConfiguration()->get('table/wrap/enabled')) {
+            $tableRenderer = new HtmlDecorator($tableRenderer, $environment->getConfiguration()->get('table/wrap/tag'), $environment->getConfiguration()->get('table/wrap/attributes'));
+        }
+
         $environment
             ->addBlockStartParser(new TableStartParser())
 
-            ->addRenderer(Table::class, new TableRenderer())
+            ->addRenderer(Table::class, $tableRenderer)
             ->addRenderer(TableSection::class, new TableSectionRenderer())
             ->addRenderer(TableRow::class, new TableRowRenderer())
             ->addRenderer(TableCell::class, new TableCellRenderer());
diff --git a/src/Renderer/HtmlDecorator.php b/src/Renderer/HtmlDecorator.php
new file mode 100644
index 00000000..46a38d92
--- /dev/null
+++ b/src/Renderer/HtmlDecorator.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the league/commonmark package.
+ *
+ * (c) Colin O'Dell <colinodell@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace League\CommonMark\Renderer;
+
+use League\CommonMark\Node\Node;
+use League\CommonMark\Util\HtmlElement;
+
+final class HtmlDecorator implements NodeRendererInterface
+{
+    private NodeRendererInterface $inner;
+    private string $tag;
+    /** @var array<string, string|string[]|bool> */
+    private array $attributes;
+    private bool $selfClosing;
+
+    /**
+     * @param array<string, string|string[]|bool> $attributes
+     */
+    public function __construct(NodeRendererInterface $inner, string $tag, array $attributes = [], bool $selfClosing = false)
+    {
+        $this->inner       = $inner;
+        $this->tag         = $tag;
+        $this->attributes  = $attributes;
+        $this->selfClosing = $selfClosing;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function render(Node $node, ChildNodeRendererInterface $childRenderer)
+    {
+        return new HtmlElement($this->tag, $this->attributes, $this->inner->render($node, $childRenderer), $this->selfClosing);
+    }
+}
diff --git a/src/Util/HtmlElement.php b/src/Util/HtmlElement.php
index 0846c4cf..b2b51613 100644
--- a/src/Util/HtmlElement.php
+++ b/src/Util/HtmlElement.php
@@ -24,7 +24,7 @@ final class HtmlElement implements \Stringable
     /** @var array<string, string|bool> */
     private array $attributes = [];
 
-    /** @var HtmlElement|HtmlElement[]|string */
+    /** @var \Stringable|\Stringable[]|string */
     private $contents;
 
     /** @psalm-readonly */
@@ -33,7 +33,7 @@ final class HtmlElement implements \Stringable
     /**
      * @param string                                $tagName     Name of the HTML tag
      * @param array<string, string|string[]|bool>   $attributes  Array of attributes (values should be unescaped)
-     * @param HtmlElement|HtmlElement[]|string|null $contents    Inner contents, pre-escaped if needed
+     * @param \Stringable|\Stringable[]|string|null $contents    Inner contents, pre-escaped if needed
      * @param bool                                  $selfClosing Whether the tag is self-closing
      */
     public function __construct(string $tagName, array $attributes = [], $contents = '', bool $selfClosing = false)
@@ -89,7 +89,7 @@ final class HtmlElement implements \Stringable
     }
 
     /**
-     * @return HtmlElement|HtmlElement[]|string
+     * @return \Stringable|\Stringable[]|string
      *
      * @psalm-immutable
      */
@@ -105,7 +105,7 @@ final class HtmlElement implements \Stringable
     /**
      * Sets the inner contents of the tag (must be pre-escaped if needed)
      *
-     * @param HtmlElement|HtmlElement[]|string $contents
+     * @param \Stringable|\Stringable[]|string $contents
      *
      * @return $this
      */


Test patch:
diff --git a/tests/functional/Extension/Table/TableMarkdownTest.php b/tests/functional/Extension/Table/TableMarkdownTest.php
index c7350f31..f80ec0b2 100644
--- a/tests/functional/Extension/Table/TableMarkdownTest.php
+++ b/tests/functional/Extension/Table/TableMarkdownTest.php
@@ -15,69 +15,35 @@ declare(strict_types=1);
 
 namespace League\CommonMark\Tests\Functional\Extension\Table;
 
+use League\CommonMark\ConverterInterface;
 use League\CommonMark\Environment\Environment;
 use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
 use League\CommonMark\Extension\Table\TableExtension;
-use League\CommonMark\Parser\MarkdownParser;
-use League\CommonMark\Renderer\HtmlRenderer;
-use League\CommonMark\Renderer\MarkdownRendererInterface;
-use PHPUnit\Framework\TestCase;
+use League\CommonMark\MarkdownConverter;
+use League\CommonMark\Tests\Functional\AbstractLocalDataTest;
 
 /**
  * @internal
  */
-final class TableMarkdownTest extends TestCase
+final class TableMarkdownTest extends AbstractLocalDataTest
 {
-    private Environment $environment;
-
-    private MarkdownParser $parser;
-
-    protected function setUp(): void
-    {
-        $this->environment = new Environment();
-        $this->environment->addExtension(new CommonMarkCoreExtension());
-        $this->environment->addExtension(new TableExtension());
-
-        $this->parser = new MarkdownParser($this->environment);
-    }
-
     /**
-     * @dataProvider dataProvider
+     * @param array<string, mixed> $config
      */
-    public function testRenderer(string $markdown, string $html, string $testName): void
+    protected function createConverter(array $config = []): ConverterInterface
     {
-        $renderer = new HtmlRenderer($this->environment);
-        $this->assertCommonMark($renderer, $markdown, $html, $testName);
+        $environment = new Environment($config);
+        $environment->addExtension(new CommonMarkCoreExtension());
+        $environment->addExtension(new TableExtension());
+
+        return new MarkdownConverter($environment);
     }
 
     /**
-     * @return array<array<string>>
+     * {@inheritDoc}
      */
-    public function dataProvider(): array
+    public function dataProvider(): iterable
     {
-        $ret = [];
-        foreach (\glob(__DIR__ . '/md/*.md') as $markdownFile) {
-            $testName = \basename($markdownFile, '.md');
-
-            $markdown = \file_get_contents($markdownFile);
-            $html     = \file_get_contents(__DIR__ . '/md/' . $testName . '.html');
-
-            $ret[] = [$markdown, $html, $testName];
-        }
-
-        return $ret;
-    }
-
-    protected function assertCommonMark(MarkdownRendererInterface $renderer, string $markdown, string $html, string $testName): void
-    {
-        $documentAST  = $this->parser->parse($markdown);
-        $actualResult = $renderer->renderDocument($documentAST);
-
-        $failureMessage  = \sprintf('Unexpected result for "%s" test', $testName);
-        $failureMessage .= "\n=== markdown ===============\n" . $markdown;
-        $failureMessage .= "\n=== expected ===============\n" . $html;
-        $failureMessage .= "\n=== got ====================\n" . $actualResult;
-
-        $this->assertEquals($html, $actualResult, $failureMessage);
+        yield from $this->loadTests(__DIR__ . '/md');
     }
 }
diff --git a/tests/functional/Extension/Table/md/wrapped.html b/tests/functional/Extension/Table/md/wrapped.html
new file mode 100644
index 00000000..a15a2f3d
--- /dev/null
+++ b/tests/functional/Extension/Table/md/wrapped.html
@@ -0,0 +1,18 @@
+<div class="table-responsive"><table>
+<thead>
+<tr>
+<th>header 1</th>
+<th>header 2</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>cell 1.1</td>
+<td>cell 1.2</td>
+</tr>
+<tr>
+<td>cell 2.1</td>
+<td>cell 2.2</td>
+</tr>
+</tbody>
+</table></div>
diff --git a/tests/functional/Extension/Table/md/wrapped.md b/tests/functional/Extension/Table/md/wrapped.md
new file mode 100644
index 00000000..e5d8e6e3
--- /dev/null
+++ b/tests/functional/Extension/Table/md/wrapped.md
@@ -0,0 +1,11 @@
+---
+table:
+    wrap:
+        enabled: true
+        attributes: { class: "table-responsive" }
+---
+
+header 1 | header 2
+-------- | --------
+cell 1.1 | cell 1.2
+cell 2.1 | cell 2.2
diff --git a/tests/unit/Renderer/Block/HtmlDecoratorTest.php b/tests/unit/Renderer/Block/HtmlDecoratorTest.php
new file mode 100644
index 00000000..cee2251d
--- /dev/null
+++ b/tests/unit/Renderer/Block/HtmlDecoratorTest.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the league/commonmark package.
+ *
+ * (c) Colin O'Dell <colinodell@gmail.com>
+ *
+ * Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
+ *  - (c) John MacFarlane
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace League\CommonMark\Tests\Unit\Renderer\Block;
+
+use League\CommonMark\Node\Node;
+use League\CommonMark\Renderer\ChildNodeRendererInterface;
+use League\CommonMark\Renderer\HtmlDecorator;
+use League\CommonMark\Renderer\NodeRendererInterface;
+use PHPUnit\Framework\TestCase;
+
+final class HtmlDecoratorTest extends TestCase
+{
+    public function testRender(): void
+    {
+        $inner = $this->getMockForAbstractClass(NodeRendererInterface::class);
+        $inner->method('render')->willReturn('INNER CONTENTS');
+
+        $decorator = new HtmlDecorator($inner, 'div', ['class' => 'foo', 'id' => 'bar'], true);
+
+        $this->assertSame('<div class="foo" id="bar">INNER CONTENTS</div>', (string) $decorator->render(
+            $this->getMockForAbstractClass(Node::class),
+            $this->getMockForAbstractClass(ChildNodeRendererInterface::class)
+        ));
+    }
+}