diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 1d2c578..4baa377 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -11,12 +11,12 @@ jobs: # Run tests on all OS's and HHVM versions, even if one fails fail-fast: false matrix: - os: [ ubuntu ] + os: [ ubuntu-20.04 ] hhvm: - '4.128' - - latest - - nightly - runs-on: ${{matrix.os}}-latest + - '4.153' + - '4.168' + runs-on: ${{matrix.os}} steps: - uses: actions/checkout@v2 - name: Create branch for version alias diff --git a/README.md b/README.md index 195672c..769cff4 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,6 @@ Originally, the Ruby GFM pipeline was the best fit; over time, we started to wan FBMarkdown exists to address all of these goals. -## Requirements - -- HHVM 3.24 or above. -- [hhvm-autoload](https://github.com/hhvm/hhvm-autoload) - ## Installing FBMarkdown hhvm composer.phar require facebook/fbmarkdown @@ -90,7 +85,8 @@ Extend `Facebook\Markdown\Inlines\Inline` or a subclass, and pass your classname There are then several approaches to rendering: - instantiate your subclass, and add support for it to a custom renderer - - instantiate your subclass, and make it implement the `Facebook\Markdown\RenderableAsHTML` interface + - instantiate your subclass, and make it implement the `Facebook\Markdown\RenderableAsXHP` interface + - Failing that, try the `Facebook\Markdown\RenderableAsHTML` interface. - if it could be replaced with several existing inlines, return a `Facebook\Markdown\Inlines\InlineSequence`, then you won't need to extend the renderer. @@ -101,7 +97,8 @@ to `$render_ctx->getBlockContext()->prependBlockTypes(...)`. There are then several approaches to rendering: - create a subclass of `Block`, and add support for it to a custom renderer - - create a subclass of `Block`, and make it implement the `Facebook\Markdown\RenderableAsHTML` interface + - create a subclass of `Block`, and make it implement the `Facebook\Markdown\RenderableAsXHP` interface + - Failing that, try the `Facebook\Markdown\RenderableAsHTML` interface. - if it could be replaced with several existing blocks, return a `Facebook\Markdown\Blocks\BlockSequence` - if it could be replaced with a paragraph of inlines, return a `Facebook\Markdown\Blocks\InlineSequenceBlock` diff --git a/composer.json b/composer.json index ad3d3a5..6dafb00 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ }, "require": { "hhvm": "^4.128", - "hhvm/type-assert": "^3.1|^4.0" + "hhvm/type-assert": "^3.1|^4.0", + "facebook/xhp-lib": "^4.1" }, "require-dev": { "hhvm/hacktest": "^2.0", diff --git a/hhast-lint.json b/hhast-lint.json index 6a389c6..e5363e5 100644 --- a/hhast-lint.json +++ b/hhast-lint.json @@ -1,3 +1,11 @@ { - "roots": [ "src/", "tests/" ] -} + "roots": [ + "src/", + "tests/" + ], + "builtinLinters": "all", + "disabledLinters": [ + "Facebook\\HHAST\\FinalOrAbstractClassLinter", + "Facebook\\HHAST\\UseStatementWithAsLinter" + ] +} \ No newline at end of file diff --git a/src/ParserContext.php b/src/ParserContext.php index 1b603fb..01d2575 100644 --- a/src/ParserContext.php +++ b/src/ParserContext.php @@ -21,7 +21,7 @@ enum SourceType: int { final class ParserContext { - const keyset DEFAULT_URI_SCHEME_ALLOW_LIST = keyset["http", "https", "irc", "mailto"]; + const keyset DEFAULT_URI_SCHEME_ALLOW_LIST = keyset['http', 'https', 'irc', 'mailto']; private BlockContext $blockContext; private InlineContext $inlineContext; diff --git a/src/_Private/ChildValidationDisablerDisposable.php b/src/_Private/ChildValidationDisablerDisposable.php new file mode 100644 index 0000000..b041ddd --- /dev/null +++ b/src/_Private/ChildValidationDisablerDisposable.php @@ -0,0 +1,29 @@ +shouldRestore = ChildValidation\is_enabled(); + ChildValidation\disable(); + } + + public function __dispose(): void { + if ($this->shouldRestore) { + ChildValidation\enable(); + } + } +} diff --git a/src/_Private/EMBED_THIS_STRING_AS_IS_WITHOUT_ESCAPING_OR_FILTERING.php b/src/_Private/EMBED_THIS_STRING_AS_IS_WITHOUT_ESCAPING_OR_FILTERING.php new file mode 100644 index 0000000..17b208f --- /dev/null +++ b/src/_Private/EMBED_THIS_STRING_AS_IS_WITHOUT_ESCAPING_OR_FILTERING.php @@ -0,0 +1,32 @@ +{new POTENTIAL_XSS_HOLE($danger_danger_danger)}; +} + +final class POTENTIAL_XSS_HOLE implements XHP\UnsafeRenderable { + public function __construct(private string $dangerDangerDanger) {} + + public async function toHTMLStringAsync(): Awaitable { + return $this->dangerDangerDanger; + } +} diff --git a/src/_Private/EscapedAttribute.php b/src/_Private/EscapedAttribute.php new file mode 100644 index 0000000..34d521b --- /dev/null +++ b/src/_Private/EscapedAttribute.php @@ -0,0 +1,22 @@ +> + public function toHTMLString(): string { + return $this->dangerDangerDanger; + } +} diff --git a/src/_Private/URI_SAFE.php b/src/_Private/URI_SAFE.php new file mode 100644 index 0000000..6fe8363 --- /dev/null +++ b/src/_Private/URI_SAFE.php @@ -0,0 +1,22 @@ + URI_SAFE = keyset[ + '-', '_', '.', '+', '!', '*', "'", '(', ')', ';', ':', '%', '#', '@', '?', + '=', ';', ':', '/', ',', '+', '&', '$', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', + 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', + 'u', 'v', 'w', 'x', 'y', 'z', + '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', +]; diff --git a/src/_Private/consume_link_title.php b/src/_Private/consume_link_title.php index 7600ad7..8b9ecd3 100644 --- a/src/_Private/consume_link_title.php +++ b/src/_Private/consume_link_title.php @@ -43,7 +43,7 @@ function consume_quoted_link_title(string $input): ?(string, int) { break; } - if ($chr === "\\") { + if ($chr === '\\') { if ($idx + 1 < $len) { $next = $input[$idx + 1]; if (C\contains_key(ASCII_PUNCTUATION, $next)) { @@ -99,7 +99,7 @@ function consume_parenthesized_link_title(string $input): ?(string, int) { continue; } - if ($chr === "\\") { + if ($chr === '\\') { if ($idx + 1 < $len) { $next = $input[$idx + 1]; if (C\contains_key(ASCII_PUNCTUATION, $next)) { diff --git a/src/_Private/decode_html_entity.php b/src/_Private/decode_html_entity.php index cb7cf98..c0e9a00 100644 --- a/src/_Private/decode_html_entity.php +++ b/src/_Private/decode_html_entity.php @@ -40,7 +40,7 @@ function decode_html_entity(string $string): ?(string, string, string) { $out = \html_entity_decode( $match, - /* HH_FIXME[4106] */ /* HH_FIXME[2049] */ \ENT_HTML5, + \ENT_HTML5, 'UTF-8', ); if ($out === $match) { diff --git a/src/_Private/disable_child_validation.php b/src/_Private/disable_child_validation.php new file mode 100644 index 0000000..c47a32e --- /dev/null +++ b/src/_Private/disable_child_validation.php @@ -0,0 +1,18 @@ +> +function disable_child_validation(): IDisposable { + return new ChildValidationDisablerDisposable(); +} diff --git a/src/_Private/escape_uri_attribute.php b/src/_Private/escape_uri_attribute.php new file mode 100644 index 0000000..5c417bd --- /dev/null +++ b/src/_Private/escape_uri_attribute.php @@ -0,0 +1,33 @@ + { $file = __DIR__.'/../../third-party/entities.json'; invariant( \file_exists($file), - "Expected %s to exist", + 'Expected %s to exist', $file, ); $data = \json_decode( diff --git a/src/_Private/td_with_align.php b/src/_Private/td_with_align.php new file mode 100644 index 0000000..e6909b1 --- /dev/null +++ b/src/_Private/td_with_align.php @@ -0,0 +1,30 @@ +> + protected static function getChildrenDeclaration(): XHPChild\Constraint { + return XHPChild\any_number_of( + XHPChild\any_of(XHPChild\pcdata(), XHPChild\of_type()), + ); + } + + protected string $tagName = 'td'; +} diff --git a/src/_Private/th_with_align.php b/src/_Private/th_with_align.php new file mode 100644 index 0000000..ecc2f34 --- /dev/null +++ b/src/_Private/th_with_align.php @@ -0,0 +1,30 @@ +> + protected static function getChildrenDeclaration(): XHPChild\Constraint { + return XHPChild\any_number_of( + XHPChild\any_of(XHPChild\pcdata(), XHPChild\of_type()), + ); + } + + protected string $tagName = 'th'; +} diff --git a/src/_Private/trim_node.php b/src/_Private/trim_node.php new file mode 100644 index 0000000..b5d85f2 --- /dev/null +++ b/src/_Private/trim_node.php @@ -0,0 +1,24 @@ +> + public async function stringifyAsync(): Awaitable { + return await Vec\map_async($this->getChildren(), static::renderChildAsync<>) + |> Str\join($$, '') + |> Str\trim($$); + } +} diff --git a/src/_Private/xhp_join.php b/src/_Private/xhp_join.php new file mode 100644 index 0000000..96f5478 --- /dev/null +++ b/src/_Private/xhp_join.php @@ -0,0 +1,43 @@ + $nodes, + ?(function(): XHPChild) $sep = null, +): XHP\Core\node { + if ($sep is null) { + return {$nodes}; + } + + $out = ; + $last = C\count($nodes) - 1; + + foreach ($nodes as $i => $node) { + $out->appendChild($node); + + if ($last !== $i) { + $out->appendChild($sep()); + } + } + + return $out; +} diff --git a/src/blocks/BlankLine.php b/src/blocks/BlankLine.php index 74162d4..ac5a284 100644 --- a/src/blocks/BlankLine.php +++ b/src/blocks/BlankLine.php @@ -10,6 +10,5 @@ namespace Facebook\Markdown\Blocks; - class BlankLine extends LeafBlock { } diff --git a/src/blocks/BlockSequence.php b/src/blocks/BlockSequence.php index d3bc4d1..75689c7 100644 --- a/src/blocks/BlockSequence.php +++ b/src/blocks/BlockSequence.php @@ -17,13 +17,13 @@ final class BlockSequence extends LeafBlock { private vec $children; - final public function __construct( + public function __construct( vec $children, ) { $this->children = Vec\filter_nulls($children); } - final public static function flatten(?Block ...$children): this { + public static function flatten(?Block ...$children): this { return new self(vec($children)); } diff --git a/src/blocks/ContainerBlock.php b/src/blocks/ContainerBlock.php index 6a2bbd1..cbad0ee 100644 --- a/src/blocks/ContainerBlock.php +++ b/src/blocks/ContainerBlock.php @@ -10,7 +10,6 @@ namespace Facebook\Markdown\Blocks; - abstract class ContainerBlock extends Block { public function __construct( protected vec $children, diff --git a/src/blocks/Document.php b/src/blocks/Document.php index 5f309a0..45881ca 100644 --- a/src/blocks/Document.php +++ b/src/blocks/Document.php @@ -10,6 +10,5 @@ namespace Facebook\Markdown\Blocks; - class Document extends ContainerBlock { } diff --git a/src/blocks/InlineSequenceBlock.php b/src/blocks/InlineSequenceBlock.php index b98e61b..d856b1a 100644 --- a/src/blocks/InlineSequenceBlock.php +++ b/src/blocks/InlineSequenceBlock.php @@ -18,13 +18,13 @@ final class InlineSequenceBlock extends LeafBlock { private vec $children; - final public function __construct( + public function __construct( vec $children, ) { $this->children = Vec\filter_nulls($children); } - final public static function flatten(?Inlines\Inline ...$children): this { + public static function flatten(?Inlines\Inline ...$children): this { return new self(vec($children)); } diff --git a/src/blocks/ThematicBreak.php b/src/blocks/ThematicBreak.php index 7b410e6..fa1d27c 100644 --- a/src/blocks/ThematicBreak.php +++ b/src/blocks/ThematicBreak.php @@ -10,6 +10,5 @@ namespace Facebook\Markdown\Blocks; - class ThematicBreak extends LeafBlock { } diff --git a/src/inlines/BackslashEscape.php b/src/inlines/BackslashEscape.php index 592b262..c59d241 100644 --- a/src/inlines/BackslashEscape.php +++ b/src/inlines/BackslashEscape.php @@ -21,7 +21,7 @@ public static function consume( string $string, int $offset, ): ?(Inline, int) { - if ($string[$offset] !== "\\") { + if ($string[$offset] !== '\\') { return null; } diff --git a/src/inlines/Context.php b/src/inlines/Context.php index 9ce9461..5bfcafc 100644 --- a/src/inlines/Context.php +++ b/src/inlines/Context.php @@ -99,7 +99,7 @@ public function disableNamedExtension(string $name): this { $this->disabledInlineTypes, Keyset\filter( self::ALL_INLINE_TYPES, - $class ==> Str\ends_with(Str\lowercase($class), "\\".$name.'extension'), + $class ==> Str\ends_with(Str\lowercase($class), '\\'.$name.'extension'), ), ); return $this; @@ -110,7 +110,7 @@ public function enableNamedExtension(string $name): this { $this->disabledInlineTypes, $class ==> !Str\ends_with( Str\lowercase($class), - "\\".Str\lowercase($name).'extension', + '\\'.Str\lowercase($name).'extension', ), ); return $this; diff --git a/src/inlines/Emphasis.php b/src/inlines/Emphasis.php index 805cb80..506bd5f 100644 --- a/src/inlines/Emphasis.php +++ b/src/inlines/Emphasis.php @@ -482,7 +482,7 @@ private static function isStartOfRun( } $previous = $markdown[$offset - 1]; - if ($previous !== "\\" && $previous !== $first) { + if ($previous !== '\\' && $previous !== $first) { return true; } diff --git a/src/inlines/Link.php b/src/inlines/Link.php index 4c0ad95..dc0de11 100644 --- a/src/inlines/Link.php +++ b/src/inlines/Link.php @@ -87,6 +87,7 @@ public static function consumeLinkish( if ($chr === ']') { --$depth; if ($depth === 0) { + // HHAST_FIXME[NoEmptyStatements] What was this supposed to do? $offset; break; } diff --git a/src/inlines/SoftLineBreak.php b/src/inlines/SoftLineBreak.php index b628488..6014d44 100644 --- a/src/inlines/SoftLineBreak.php +++ b/src/inlines/SoftLineBreak.php @@ -10,7 +10,6 @@ namespace Facebook\Markdown\Inlines; - class SoftLineBreak extends Inline { public function __construct() { } diff --git a/src/inlines/_Private/parse_with_blacklist.php b/src/inlines/_Private/parse_with_blacklist.php index ba16f18..a951c93 100644 --- a/src/inlines/_Private/parse_with_blacklist.php +++ b/src/inlines/_Private/parse_with_blacklist.php @@ -41,7 +41,7 @@ function parse_with_denylist ( list($inline, $new_offset) = $result; invariant( $new_offset > $offset, - "Failed to consume any data with %s", + 'Failed to consume any data with %s', \get_class($inline), ); $offset = $new_offset; diff --git a/src/render/HTMLRenderer.php b/src/render/HTMLRenderer.php index 61ca072..c935e02 100644 --- a/src/render/HTMLRenderer.php +++ b/src/render/HTMLRenderer.php @@ -11,8 +11,18 @@ namespace Facebook\Markdown; use namespace HH\Lib\{C, Str, Vec}; +use namespace HH\Asio; -// TODO: fix namespace support in XHP, use that :'( +/** + * You probably want to use `HTMLXHPRenderer` or failing that + * `HTMLWithXHPInternallyRenderer`. These two renderers are built with xhp, + * which automates the escaping of attributes and text nodes. + * + * `HTMLRenderer` uses string concatenation and manual escaping under the hood. + * Great care is taken to escape user data, but when this is done manually, + * bugs can slip through. Strongly consider the other renderers in security + * critical contexts. + */ class HTMLRenderer extends Renderer { const keyset> EXTENSIONS = keyset[ TagFilterExtension::class, @@ -27,6 +37,7 @@ protected static function escapeAttribute(string $text): string { } // This is the list from the reference implementation + // Unused, but kept for backwards compatibility. //hackfmt-ignore const keyset URI_SAFE = keyset[ '-', '_', '.', '+', '!', '*', "'", '(', ')', ';', ':', '%', '#', '@', '?', @@ -38,42 +49,33 @@ protected static function escapeAttribute(string $text): string { ]; protected static function escapeURIAttribute(string $text): string { - // While the spec states that no particular method is required, we attempt - // to match cmark's behavior so that we can run the spec test suite. - $text = \html_entity_decode( - $text, - /* HH_FIXME[4106] */ /* HH_FIXME[2049] */ \ENT_HTML5, - 'UTF-8', - ); - - $out = ''; - $len = Str\length($text); - for ($i = 0; $i < $len; ++$i) { - $char = $text[$i]; - if (C\contains_key(self::URI_SAFE, $char)) { - $out .= $char; - continue; - } - $out .= \urlencode($char); - } - $text = $out; - - return self::escapeAttribute($text); + return _Private\escape_uri_attribute($text)->toHTMLString(); } <<__Override>> protected function renderNodes(vec $nodes): string { return $nodes |> Vec\map($$, $node ==> $this->render($node)) - |> Vec\filter($$, $line ==> $line !== '') |> Str\join($$, ''); } <<__Override>> protected function renderResolvedNode(ASTNode $node): string { + if ($node is RenderableAsXHP) { + $xhp_renderer = new HTMLXHPRenderer($this->getContext()); + // HHAST_IGNORE_ERROR[DontUseAsioJoin] + return Asio\join( + $node->renderAsXHP($this->getContext(), $xhp_renderer)->toStringAsync(), + ); + } + + // This interface is implemented by users of this library. + // It must remain unchanged for backwards compatibility. + // Ideally users would switch over to RenderableAsXHP. if ($node is RenderableAsHTML) { return $node->renderAsHTML($this->getContext(), $this); } + return parent::renderResolvedNode($node); } @@ -255,7 +257,7 @@ protected function renderTableDataRow( int $row_idx, Blocks\TableExtension::TRow $row, ): string { - $html = ""; + $html = ''; for ($i = 0; $i < C\count($row); ++$i) { $cell = $row[$i]; @@ -275,7 +277,7 @@ protected function renderTableDataCell( if ($alignment !== null) { $alignment = ' align="'.$alignment.'"'; } - return "'.$this->renderNodes($cell).""; + return ''.$this->renderNodes($cell).''; } <<__Override>> @@ -287,9 +289,8 @@ protected function renderThematicBreak(): string { protected function renderAutoLink(Inlines\AutoLink $node): string { $href = self::escapeURIAttribute($node->getDestination()); $text = self::escapeContent($node->getText()); - $noFollowUgcTag = $this->getContext()->areLinksNoFollowUGC() - ? ' rel="nofollow ugc"' - : ''; + $noFollowUgcTag = + $this->getContext()->areLinksNoFollowUGC() ? ' rel="nofollow ugc"' : ''; return ''.$text.''; } @@ -344,10 +345,10 @@ protected function renderLink(Inlines\Link $node): string { $text = $node->getText() |> Vec\map($$, $child ==> $this->render($child)) |> Str\join($$, ''); - $noFollowUgcTag = $this->getContext()->areLinksNoFollowUGC() - ? ' rel="nofollow ugc"' - : ''; - return ''.$text.''; + $noFollowUgcTag = + $this->getContext()->areLinksNoFollowUGC() ? ' rel="nofollow ugc"' : ''; + return + ''.$text.''; } <<__Override>> diff --git a/src/render/HTMLWithXHPInternallyRenderer.php b/src/render/HTMLWithXHPInternallyRenderer.php new file mode 100644 index 0000000..733afb2 --- /dev/null +++ b/src/render/HTMLWithXHPInternallyRenderer.php @@ -0,0 +1,154 @@ +, so HTMLWithXHPInternallyRenderer is too. +// This forces us to run the Awaitables in a blocking fashion. + +use namespace HH\Asio; +use namespace Facebook\XHP; + +final class HTMLWithXHPInternallyRenderer extends Renderer { + private IRenderer $impl; + + public function __construct(RenderContext $context) { + parent::__construct($context); + $this->impl = new HTMLXHPRenderer($context); + } + + <<__Override>> + protected function renderNodes(vec $nodes): string { + return $this->impl->renderNodes($nodes) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderResolvedNode(ASTNode $node): string { + return + $this->impl->renderResolvedNode($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderBlankLine(): string { + return $this->impl->renderBlankLine() |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderBlockQuote(Blocks\BlockQuote $node): string { + return + $this->impl->renderBlockQuote($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderCodeBlock(Blocks\CodeBlock $node): string { + return + $this->impl->renderCodeBlock($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderHeading(Blocks\Heading $node): string { + return $this->impl->renderHeading($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderHTMLBlock(Blocks\HTMLBlock $node): string { + return + $this->impl->renderHTMLBlock($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderLinkReferenceDefinition( + Blocks\LinkReferenceDefinition $def, + ): string { + return $this->impl->renderLinkReferenceDefinition($def) + |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderListOfItems(Blocks\ListOfItems $node): string { + return + $this->impl->renderListOfItems($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderParagraph(Blocks\Paragraph $node): string { + return + $this->impl->renderParagraph($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderTableExtension(Blocks\TableExtension $node): string { + return $this->impl->renderTableExtension($node) + |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderThematicBreak(): string { + return $this->impl->renderThematicBreak() |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderAutoLink(Inlines\AutoLink $node): string { + return $this->impl->renderAutoLink($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderInlineWithPlainTextContent( + Inlines\InlineWithPlainTextContent $node, + ): string { + return $this->impl->renderInlineWithPlainTextContent($node) + |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderCodeSpan(Inlines\CodeSpan $node): string { + return $this->impl->renderCodeSpan($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderEmphasis(Inlines\Emphasis $node): string { + return $this->impl->renderEmphasis($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderHardLineBreak(): string { + return $this->impl->renderHardLineBreak() |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderImage(Inlines\Image $node): string { + return $this->impl->renderImage($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderLink(Inlines\Link $node): string { + return $this->impl->renderLink($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderRawHTML(Inlines\RawHTML $node): string { + return $this->impl->renderRawHTML($node) |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderSoftLineBreak(): string { + return $this->impl->renderSoftLineBreak() |> Asio\join($$->toStringAsync()); + } + + <<__Override>> + protected function renderStrikethroughExtension( + Inlines\StrikethroughExtension $node, + ): string { + return $this->impl->renderStrikethroughExtension($node) + |> Asio\join($$->toStringAsync()); + } +} diff --git a/src/render/HTMLXHPRenderer.php b/src/render/HTMLXHPRenderer.php new file mode 100644 index 0000000..809d475 --- /dev/null +++ b/src/render/HTMLXHPRenderer.php @@ -0,0 +1,391 @@ + { + const keyset> EXTENSIONS = keyset[ + TagFilterExtension::class, + ]; + + <<__Override>> + protected function renderNodes(vec $nodes): XHP\Core\node { + return $nodes + |> Vec\map($$, $node ==> $this->render($node)) + |> _Private\xhp_join($$); + } + + <<__Override>> + protected function renderResolvedNode(ASTNode $node): XHP\Core\node { + if ($node is RenderableAsXHP) { + return $node->renderAsXHP($this->getContext(), $this); + } + + // This interface is implemented by users of this library. + // It must remain unchanged for backwards compatibility. + // Ideally users would switch over to RenderableAsXHP. + if ($node is RenderableAsHTML) { + $string_renderer = new HTMLRenderer($this->getContext()); + return $node->renderAsHTML($this->getContext(), $string_renderer) + |> _Private\EMBED_THIS_STRING_AS_IS_WITHOUT_ESCAPING_OR_FILTERING($$); + } + + return parent::renderResolvedNode($node); + } + + <<__Override>> + protected function renderBlankLine(): XHP\Core\node { + return ; + } + + <<__Override>> + protected function renderBlockQuote(Blocks\BlockQuote $node): XHP\Core\node { + return $node->getChildren() + |> $this->renderNodes($$) + |>
{"\n"}{$$}
{"\n"}
; + } + + <<__Override>> + protected function renderCodeBlock(Blocks\CodeBlock $node): XHP\Core\node { + $lang = $node->getInfoString() + |> $$ is null ? $$ : 'language-'.C\firstx(Str\split($$, ' ')); + $code = $node->getCode() |> $$ === '' ? $$ : $$."\n"; + + return
{$code}
{"\n"}
; + } + + <<__Override>> + protected function renderHeading(Blocks\Heading $node): XHP\Core\node { + $children = $this->renderNodes($node->getHeading()); + switch ($node->getLevel()) { + case 1: + return

{$children}

{"\n"}
; + case 2: + return

{$children}

{"\n"}
; + case 3: + return

{$children}

{"\n"}
; + case 4: + return

{$children}

{"\n"}
; + case 5: + return
{$children}
{"\n"}
; + case 6: + return
{$children}
{"\n"}
; + default: + invariant_violation(' is not a valid html tag', $node->getLevel()); + } + } + + <<__Override>> + protected function renderHTMLBlock(Blocks\HTMLBlock $node): XHP\Core\node { + return $node->getCode()."\n" + |> _Private\EMBED_THIS_STRING_AS_IS_WITHOUT_ESCAPING_OR_FILTERING($$); + } + + <<__Override>> + protected function renderLinkReferenceDefinition( + Blocks\LinkReferenceDefinition $_def, + ): XHP\Core\node { + return ; + } + + protected function renderTaskListItemExtension( + Blocks\ListOfItems $list, + Blocks\TaskListItemExtension $item, + ): XHP\Core\node { + $checked = $item->isChecked() ? ' checked=""' : ''; + $checkbox = ' '; + + $children = $item->getChildren(); + $first = C\first($children); + if ($first is Blocks\Paragraph) { + $children[0] = new Blocks\Paragraph( + Vec\concat(vec[new Inlines\RawHTML($checkbox)], $first->getContents()), + ); + } else { + $children = Vec\concat(vec[new Blocks\HTMLBlock($checkbox)], $children); + } + + return $this->renderListItem( + $list, + new Blocks\ListItem($item->getNumber(), $children), + ); + } + + protected function renderListItem( + Blocks\ListOfItems $list, + Blocks\ListItem $item, + ): XHP\Core\node { + if ($item is Blocks\TaskListItemExtension) { + return $this->renderTaskListItemExtension($list, $item); + } + + $children = $item->getChildren(); + if (C\is_empty($children)) { + return
  • {"\n"}
    ; + } + + if ($list->isLoose()) { + return
  • {"\n"}{$this->renderNodes($children)}
  • {"\n"}
    ; + } + + return + +
  • + {C\firstx($children) is Blocks\Paragraph ? null : "\n"} + { + Vec\map( + $children, + $child ==> { + if ($child is Blocks\Paragraph) { + return $this->renderNodes($child->getContents()); + } + if ($child is Blocks\Block) { + return {$this->render($child)}; + } + return $this->render($child); + }, + ) + |> _Private\xhp_join($$, () ==> "\n") + } + {C\lastx($children) is Blocks\Paragraph ? null : "\n"} +
  • + {"\n"} +
    ; + } + + <<__Override>> + protected function renderListOfItems( + Blocks\ListOfItems $node, + ): XHP\Core\node { + $children = + Vec\map($node->getItems(), $item ==> $this->renderListItem($node, $item)); + + $start = $node->getFirstNumber(); + switch ($start) { + // HHAST_IGNORE_ERROR[5614] Intended this to be a null === ?int comparison + case null: + return
      {"\n"}{$children}
    {"\n"}
    ; + // HHAST_IGNORE_ERROR[5614] No nulls here, because that's the first case. + case 1: + return
      {"\n"}{$children}
    {"\n"}
    ; + default: + return
      {"\n"}{$children}
    {"\n"}
    ; + } + } + + <<__Override>> + protected function renderParagraph(Blocks\Paragraph $node): XHP\Core\node { + return

    {$this->renderNodes($node->getContents())}

    {"\n"}
    ; + } + + <<__Override>> + protected function renderTableExtension( + Blocks\TableExtension $node, + ): XHP\Core\node { + $header = $this->renderTableHeader($node); + + $data = $node->getData(); + if (C\is_empty($data)) { + return {"\n"}{$header}
    {"\n"}
    ; + } + + return + + + {"\n"} + {$header} + {"\n"} + + {Vec\map_with_key( + $data, + ($i, $row) ==> + {"\n"}{$this->renderTableDataRow($node, $i, $row)}, + )} + +
    + {"\n"} +
    ; + } + + protected function renderTableHeader( + Blocks\TableExtension $node, + ): XHP\Core\node { + $alignments = $node->getColumnAlignments(); + return + + {"\n"} + + {"\n"} + {Vec\map_with_key( + $node->getHeader(), + ($i, $cell) ==> + + $$ is null ? null : $$.''}> + {$this->renderNodes($cell)} + + {"\n"} + , + )} + + {"\n"} + ; + } + + protected function renderTableDataRow( + Blocks\TableExtension $table, + int $_row_idx, + Blocks\TableExtension::TRow $row, + ): XHP\Core\node { + return + + {Vec\map_with_key( + $row, + ($i, $cell) ==> + + {"\n"} + {$this->renderTableDataCell($table, -1, $i, $cell)} + , + )} + {"\n"} + ; + } + + protected function renderTableDataCell( + Blocks\TableExtension $table, + int $_row_idx, + int $col_idx, + Blocks\TableExtension::TCell $cell, + ): XHP\Core\node { + $align = + $table->getColumnAlignments()[$col_idx] |> $$ is null ? null : $$.''; + return + {$this->renderNodes($cell)}; + } + + <<__Override>> + protected function renderThematicBreak(): XHP\Core\node { + return
    {"\n"}
    ; + } + + <<__Override>> + protected function renderAutoLink(Inlines\AutoLink $node): XHP\Core\node { + $href = _Private\escape_uri_attribute($node->getDestination()); + $rel = $this->getContext()->areLinksNoFollowUGC() ? 'nofollow ugc' : null; + + $donor = ; + $donor->forceAttribute_DEPRECATED('href', $href); + return {$node->getText()}; + } + + <<__Override>> + protected function renderInlineWithPlainTextContent( + Inlines\InlineWithPlainTextContent $node, + ): XHP\Core\node { + return {$node->getContent()}; + } + + <<__Override>> + protected function renderCodeSpan(Inlines\CodeSpan $node): XHP\Core\node { + return {$node->getCode()}; + } + + <<__Override>> + protected function renderEmphasis(Inlines\Emphasis $node): XHP\Core\node { + $children = Vec\map($node->getContent(), $item ==> $this->render($item)); + return + $node->isStrong() ? {$children} : {$children}; + } + + <<__Override>> + protected function renderHardLineBreak(): XHP\Core\node { + return
    {"\n"}
    ; + } + + <<__Override>> + protected function renderImage(Inlines\Image $node): XHP\Core\node { + $title = $node->getTitle(); + $src = _Private\escape_uri_attribute($node->getSource()); + // Needs to always be present for spec tests to pass + $alt = $node->getDescription() + |> Vec\map($$, $child ==> $child->getContentAsPlainText()) + |> Str\join($$, ''); + + $donor = ; + $donor->forceAttribute_DEPRECATED('src', $src); + return {$alt}; + } + + <<__Override>> + protected function renderLink(Inlines\Link $node): XHP\Core\node { + $title = $node->getTitle(); + $rel = $this->getContext()->areLinksNoFollowUGC() ? 'nofollow ugc' : null; + + $href = _Private\escape_uri_attribute($node->getDestination()); + + $text = $node->getText() + |> Vec\map($$, $child ==> $this->render($child)); + + $donor = ; + $donor->forceAttribute_DEPRECATED('href', $href); + return {$text}; + } + + <<__Override>> + protected function renderRawHTML(Inlines\RawHTML $node): XHP\Core\node { + return $node->getContent() + |> _Private\EMBED_THIS_STRING_AS_IS_WITHOUT_ESCAPING_OR_FILTERING($$); + } + + <<__Override>> + protected function renderSoftLineBreak(): XHP\Core\node { + return {"\n"}; + } + + <<__Override>> + protected function renderStrikethroughExtension( + Inlines\StrikethroughExtension $node, + ): XHP\Core\node { + return $node->getChildren() + |> Vec\map($$, $child ==> $this->render($child)) + |> {$$}; + } +} diff --git a/src/render/IRenderer.php b/src/render/IRenderer.php new file mode 100644 index 0000000..debfb8e --- /dev/null +++ b/src/render/IRenderer.php @@ -0,0 +1,54 @@ + { + protected function getContext(): RenderContext; + + public function render(ASTNode $node): T; + + protected function renderNodes(vec $nodes): T; + + ///// blocks ///// + + protected function renderBlankLine(): T; + protected function renderBlockQuote(Blocks\BlockQuote $node): T; + protected function renderCodeBlock(Blocks\CodeBlock $node): T; + protected function renderDocument(Blocks\Document $node): T; + protected function renderHeading(Blocks\Heading $node): T; + protected function renderHTMLBlock(Blocks\HTMLBlock $node): T; + protected function renderLinkReferenceDefinition( + Blocks\LinkReferenceDefinition $node, + ): T; + protected function renderListOfItems(Blocks\ListOfItems $node): T; + protected function renderParagraph(Blocks\Paragraph $node): T; + protected function renderTableExtension(Blocks\TableExtension $node): T; + protected function renderThematicBreak(): T; + + ///// inlines //// + + protected function renderAutoLink(Inlines\AutoLink $node): T; + protected function renderCodeSpan(Inlines\CodeSpan $node): T; + protected function renderEmphasis(Inlines\Emphasis $node): T; + protected function renderHardLineBreak(): T; + protected function renderImage(Inlines\Image $node): T; + protected function renderInlineWithPlainTextContent( + Inlines\InlineWithPlainTextContent $node, + ): T; + protected function renderLink(Inlines\Link $node): T; + protected function renderRawHTML(Inlines\RawHTML $node): T; + protected function renderSoftLineBreak(): T; + protected function renderStrikethroughExtension( + Inlines\StrikethroughExtension $node, + ): T; + + protected function renderResolvedNode(ASTNode $node): T; +} diff --git a/src/render/MarkdownRenderer.php b/src/render/MarkdownRenderer.php index c0fd999..c6ba5c3 100644 --- a/src/render/MarkdownRenderer.php +++ b/src/render/MarkdownRenderer.php @@ -223,10 +223,10 @@ protected function renderParagraph(Blocks\Paragraph $node): string { $line ==> { $parsed = UnparsedBlocks\parse($ctx, $line)->getChildren(); if (!C\firstx($parsed) is UnparsedBlocks\Paragraph) { - return " ".$line; + return ' '.$line; } if (\preg_match('/^ {0,3}[=-]+ *$/', $line)) { - return "\\".$line; + return '\\'.$line; } return $line; }, @@ -286,7 +286,7 @@ protected function renderTableDataCell( Blocks\TableExtension::TCell $cell, ): string { return $this->renderNodes($cell) - |> Str\replace($$, "|", "\\|"); + |> Str\replace($$, '|', '\\|'); } <<__Override>> @@ -307,7 +307,7 @@ protected function renderInlineWithPlainTextContent( Inlines\InlineWithPlainTextContent $node, ): string { if ($node is Inlines\BackslashEscape) { - return "\\".$node->getContent(); + return '\\'.$node->getContent(); } if ($node is Inlines\EntityReference) { // This matters if the entity reference is for whitespace: if we print @@ -369,7 +369,7 @@ protected function renderHardLineBreak(): string { protected function renderImage(Inlines\Image $node): string { $t = $node->getTitle(); return Str\format( - "![%s](<%s>%s)", + '![%s](<%s>%s)', $this->renderNodes($node->getDescription()), $node->getSource(), $t === null ? '' : (' "'.$t.'"'), diff --git a/src/render/RenderContext.php b/src/render/RenderContext.php index f67ee73..ef3b4d6 100644 --- a/src/render/RenderContext.php +++ b/src/render/RenderContext.php @@ -60,7 +60,7 @@ public function areLinksNoFollowUGC(): bool { public function disableNamedExtension(string $extension): this { $this->enabledExtensions = Vec\filter( $this->enabledExtensions, - $obj ==> !Str\ends_with_ci(\get_class($obj), "\\".$extension.'Extension'), + $obj ==> !Str\ends_with_ci(\get_class($obj), '\\'.$extension.'Extension'), ); return $this; } @@ -68,7 +68,7 @@ public function disableNamedExtension(string $extension): this { public function disableImageFiltering(): this { foreach ($this->extensions as $extension) { if ($extension is TagFilterExtension) { - $extension->removeFromTagBlacklist(keyset["removeFromTagBlacklist(keyset[' Vec\filter( $$, $obj ==> - Str\ends_with_ci(\get_class($obj), "\\".$extension.'Extension'), + Str\ends_with_ci(\get_class($obj), '\\'.$extension.'Extension'), ) |> Vec\concat($$, $this->enabledExtensions) - |> Vec\unique_by($$, $x ==> \get_class($x)); + |> Vec\unique_by($$, \get_class<>); return $this; } diff --git a/src/render/RenderableAsHTML.php b/src/render/RenderableAsHTML.php index 887f862..c0e7389 100644 --- a/src/render/RenderableAsHTML.php +++ b/src/render/RenderableAsHTML.php @@ -10,6 +10,9 @@ namespace Facebook\Markdown; +/** + * @see RenderableAsXHP and use it if you can. + */ interface RenderableAsHTML { public function renderAsHTML( RenderContext $context, diff --git a/src/render/RenderableAsXHP.php b/src/render/RenderableAsXHP.php new file mode 100644 index 0000000..93204fd --- /dev/null +++ b/src/render/RenderableAsXHP.php @@ -0,0 +1,20 @@ + { +abstract class Renderer implements IRenderer { public function __construct( private RenderContext $context, ) { @@ -173,7 +173,7 @@ protected function renderResolvedNode( } invariant_violation( - "Unhandled node type: %s", + 'Unhandled node type: %s', \get_class($node), ); } diff --git a/src/unparsed-blocks/BlockQuote.php b/src/unparsed-blocks/BlockQuote.php index 8be04ff..0c1c4cf 100644 --- a/src/unparsed-blocks/BlockQuote.php +++ b/src/unparsed-blocks/BlockQuote.php @@ -46,7 +46,6 @@ public static function consume( $parsed = null; } - if (C\is_empty($contents)) { return null; } diff --git a/src/unparsed-blocks/BlockSequence.php b/src/unparsed-blocks/BlockSequence.php index dba5c31..473dd4d 100644 --- a/src/unparsed-blocks/BlockSequence.php +++ b/src/unparsed-blocks/BlockSequence.php @@ -19,17 +19,17 @@ final class BlockSequence extends Block implements BlockProducer { private vec $children; - final public function __construct( + public function __construct( vec $children, ) { $this->children = Vec\filter_nulls($children); } - final public function getChildren(): vec { + public function getChildren(): vec { return $this->children; } - final public static function flatten(?Block ...$children): this { + public static function flatten(?Block ...$children): this { return new self(vec($children)); } diff --git a/src/unparsed-blocks/ContainerBlock.php b/src/unparsed-blocks/ContainerBlock.php index 59ac7e5..53ba4db 100644 --- a/src/unparsed-blocks/ContainerBlock.php +++ b/src/unparsed-blocks/ContainerBlock.php @@ -12,7 +12,6 @@ use namespace HH\Lib\Vec; - abstract class ContainerBlock extends Block { public function __construct( diff --git a/src/unparsed-blocks/Context.php b/src/unparsed-blocks/Context.php index 78d209e..4d353f9 100644 --- a/src/unparsed-blocks/Context.php +++ b/src/unparsed-blocks/Context.php @@ -56,7 +56,7 @@ public function disableNamedExtension(string $name): this { self::ALL_BLOCK_TYPES, $class ==> Str\ends_with( Str\lowercase($class), - "\\".Str\lowercase($name).'extension', + '\\'.Str\lowercase($name).'extension', ), ), ); @@ -66,7 +66,7 @@ public function disableNamedExtension(string $name): this { public function enableNamedExtension(string $name): this { $this->disabledBlockTypes = Keyset\filter( $this->disabledBlockTypes, - $class ==> !Str\ends_with(Str\lowercase($class), "\\".$name.'extension'), + $class ==> !Str\ends_with(Str\lowercase($class), '\\'.$name.'extension'), ); return $this; } @@ -156,7 +156,7 @@ public function pushContext(string $context, mixed $value): this { public function popContext(string $context): this { $stack = $this->stacks[$context] ?? vec[]; $count = C\count($stack) - 1; - invariant($count >= 0, "Trying to pop more than was pushed"); + invariant($count >= 0, 'Trying to pop more than was pushed'); $stack = Vec\take($stack, $count); $this->stacks[$context] = $stack; return $this; diff --git a/src/unparsed-blocks/FencedBlock.php b/src/unparsed-blocks/FencedBlock.php index 2c86dc3..a6fb1aa 100644 --- a/src/unparsed-blocks/FencedBlock.php +++ b/src/unparsed-blocks/FencedBlock.php @@ -10,7 +10,6 @@ namespace Facebook\Markdown\UnparsedBlocks; - abstract class FencedBlock extends LeafBlock implements BlockProducer { protected abstract static function createFromLines( vec $lines, diff --git a/src/unparsed-blocks/FencedCodeBlock.php b/src/unparsed-blocks/FencedCodeBlock.php index 3ec3ce6..940029b 100644 --- a/src/unparsed-blocks/FencedCodeBlock.php +++ b/src/unparsed-blocks/FencedCodeBlock.php @@ -58,7 +58,7 @@ protected static function createFromLines( for ($i = 0; $i < $len; ++$i) { $char = $info[$i]; if ( - $char === "\\" + $char === '\\' && $i + 1 < $len ) { $next = $info[$i + 1]; diff --git a/src/unparsed-blocks/HTMLBlock.php b/src/unparsed-blocks/HTMLBlock.php index 2d1baea..f0cb5dc 100644 --- a/src/unparsed-blocks/HTMLBlock.php +++ b/src/unparsed-blocks/HTMLBlock.php @@ -27,9 +27,9 @@ class HTMLBlock extends FencedBlock { self::SINGLE_QUOTED_ATTRIBUTE_VALUE.'|'. self::DOUBLE_QUOTED_ATTRIBUTE_VALUE. ')'; - const string ATTRIBUTE_VALUE_SPECIFICATION = "\\s*=\\s*".self::ATTRIBUTE_VALUE; + const string ATTRIBUTE_VALUE_SPECIFICATION = '\\s*=\\s*'.self::ATTRIBUTE_VALUE; const string ATTRIBUTE = - "\\s+".self::ATTRIBUTE_NAME.'('.self::ATTRIBUTE_VALUE_SPECIFICATION.')?'; + '\\s+'.self::ATTRIBUTE_NAME.'('.self::ATTRIBUTE_VALUE_SPECIFICATION.')?'; const dict PARAGRAPH_INTERRUPTING_PATTERNS = dict[ // GFM spec states that closing tag doesn't need to match opening tag diff --git a/src/unparsed-blocks/LinkReferenceDefinition.php b/src/unparsed-blocks/LinkReferenceDefinition.php index 3e8216a..456057d 100644 --- a/src/unparsed-blocks/LinkReferenceDefinition.php +++ b/src/unparsed-blocks/LinkReferenceDefinition.php @@ -45,7 +45,7 @@ public function getKey(): string { public static function normalizeKey(string $in): string { return $in |> Str\trim($$) - |> \mb_convert_case($$, \MB_CASE_LOWER, "UTF-8") + |> \mb_convert_case($$, \MB_CASE_LOWER, 'UTF-8') |> \preg_replace('/\s+/', ' ', $$); } @@ -183,9 +183,9 @@ private static function consumeLabel(Lines $lines): ?(string, Lines) { if ($char === ']') { break; } - if ($char === "\\") { + if ($char === '\\') { if ($i + 1 < $len) { - $label .= "\\".$line[$i + 1]; + $label .= '\\'.$line[$i + 1]; ++$i; continue; } diff --git a/src/unparsed-blocks/TableExtension.php b/src/unparsed-blocks/TableExtension.php index 882630f..971f9e6 100644 --- a/src/unparsed-blocks/TableExtension.php +++ b/src/unparsed-blocks/TableExtension.php @@ -142,7 +142,7 @@ private static function consumeRow( $parts = vec[]; $start = 0; $len = Str\length($first); - while ($start !== null && $start < $len) { + while ($start < $len) { $end = Str\search($first, '|', $start); if ($end === null) { $parts[] = Str\slice($first, $start); @@ -178,7 +178,7 @@ private static function consumeRow( $definitely_row, Vec\map( $parts, - $part ==> Str\trim($part) |> Str\replace($$, "\\|", '|'), + $part ==> Str\trim($part) |> Str\replace($$, '\\|', '|'), ), $rest, ); diff --git a/src/unparsed-blocks/_Private/is_paragraph_continuation_text.php b/src/unparsed-blocks/_Private/is_paragraph_continuation_text.php index 03b907b..1ada580 100644 --- a/src/unparsed-blocks/_Private/is_paragraph_continuation_text.php +++ b/src/unparsed-blocks/_Private/is_paragraph_continuation_text.php @@ -8,7 +8,7 @@ * */ -namespace Facebook\Markdown\UnparsedBlocks\_Private;; +namespace Facebook\Markdown\UnparsedBlocks\_Private; use type Facebook\Markdown\UnparsedBlocks\{BlockProducer, Context, Lines}; use namespace HH\Lib\C; diff --git a/tests/EdgeCaseTest.php b/tests/EdgeCaseTest.php index 4823e50..fc95d86 100644 --- a/tests/EdgeCaseTest.php +++ b/tests/EdgeCaseTest.php @@ -12,6 +12,7 @@ use type Facebook\HackTest\DataProvider; use function Facebook\FBExpect\expect; +use type XHPChild; final class EdgeCaseTest extends TestCase { public function getManualExamples(): vec<(string, string)> { @@ -58,8 +59,11 @@ public function getManualExamples(): vec<(string, string)> { } <> - public function testManualExample(string $in, string $expected_html): void { - $this->assertExampleMatches( + public async function testManualExample( + string $in, + string $expected_html, + ): Awaitable { + await $this->assertExampleMatchesAsync( 'unnamed', $in, $expected_html, @@ -67,18 +71,22 @@ public function testManualExample(string $in, string $expected_html): void { ); } - public function testTagFilter(): void { + <> + public async function testTagFilter( + (function(RenderContext): IRenderer) $constructor, + ): Awaitable { $ast = parse( (new ParserContext())->setSourceType(SourceType::TRUSTED), '