Skip to content

Commit ce5bf0e

Browse files
Add support for indented HTML blocks. (#33)
1 parent f47e1c6 commit ce5bf0e

9 files changed

Lines changed: 149 additions & 8 deletions

File tree

‎context/extensions.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,42 @@ Without the flag, the prefix remains ordinary text. Inline code information is
210210
a single language token; richer code-block information belongs on a fenced code
211211
block instead.
212212

213+
### HTML Block Blank Lines
214+
215+
CommonMark ends type 6 and 7 HTML blocks at the first blank line. This can be
216+
surprising when a large HTML fragment is formatted with blank lines between
217+
consistently indented child elements: the following child is parsed as Markdown,
218+
often as an indented code block.
219+
220+
`Markly::HTML_BLOCK_BLANK_LINES` allows those HTML blocks to continue across blank
221+
lines when their content establishes and preserves indentation:
222+
223+
``` ruby
224+
markdown = <<~MARKDOWN
225+
<div class="diagram">
226+
<div class="request">GET /slides</div>
227+
228+
<div class="response">200 OK</div>
229+
</div>
230+
MARKDOWN
231+
232+
Markly.render_html(
233+
markdown,
234+
parse_flags: Markly::HTML_BLOCK_BLANK_LINES,
235+
render_flags: Markly::UNSAFE,
236+
)
237+
```
238+
239+
The indentation may be established after an initial blank line. Once
240+
established, the first nonblank line with less indentation ends the HTML block.
241+
This keeps the extension deterministic without attempting to match or interpret
242+
HTML tags.
243+
244+
This option deliberately changes CommonMark parsing and is disabled by default.
245+
Use it for trusted, author-written documents where multiline HTML is a supported
246+
part of the Markdown dialect. `Markly::UNSAFE` is still required when rendering
247+
the resulting raw HTML.
248+
213249
### Code Block Metadata
214250

215251
`Node#code_info` is the general information-string accessor for fenced code

‎context/getting-started.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Markly accepts integer flags which control how the Markdown is parsed and render
4545
| `Markly::FRONT_MATTER` | Parse front matter at the start of the document.
4646
| `Markly::FOOTNOTES` | Parse footnotes.
4747
| `Markly::INLINE_CODE_INFO` | Parse language prefixes such as `ruby:` on inline code spans.
48+
| `Markly::HTML_BLOCK_BLANK_LINES` | Allow consistently indented HTML content to continue across blank lines.
4849
| `Markly::LIBERAL_HTML_TAG` | Support liberal parsing of inline HTML tags.
4950
| `Markly::SMART` | Use smart punctuation (curly quotes, etc.).
5051
| `Markly::STRIKETHROUGH_DOUBLE_TILDE` | Parse strikethroughs by double tildes (compatibility with [redcarpet](https://github.com/vmg/redcarpet))

‎ext/markly/blocks.c‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ static CMARK_INLINE bool S_ends_on_current_line(cmark_parser *parser, cmark_node
9191
// similar to fenced code blocks.
9292
// Types 6-7 end at a blank line, so their last content line is
9393
// the previous line and they should NOT match here.
94-
(S_type(b) == CMARK_NODE_HTML_BLOCK && b->as.html_block_type >= 1 &&
95-
b->as.html_block_type <= 5) ||
94+
(S_type(b) == CMARK_NODE_HTML_BLOCK && b->as.html_block.type >= 1 &&
95+
b->as.html_block.type <= 5) ||
9696
// Single-line blocks: finalized on same line they started
9797
b->start_line == parser->line_number;
9898
}
@@ -1037,7 +1037,7 @@ static bool parse_code_block_prefix(cmark_parser *parser, cmark_chunk *input,
10371037
static bool parse_html_block_prefix(cmark_parser *parser,
10381038
cmark_node *container) {
10391039
bool res = false;
1040-
int html_block_type = container->as.html_block_type;
1040+
int html_block_type = container->as.html_block.type;
10411041

10421042
assert(html_block_type >= 1 && html_block_type <= 7);
10431043
switch (html_block_type) {
@@ -1051,7 +1051,32 @@ static bool parse_html_block_prefix(cmark_parser *parser,
10511051
break;
10521052
case 6:
10531053
case 7:
1054-
res = !parser->blank;
1054+
if (!(parser->options & CMARK_OPT_HTML_BLOCK_BLANK_LINES)) {
1055+
res = !parser->blank;
1056+
} else if (parser->blank) {
1057+
// Tentatively retain blank lines. The next nonblank line determines
1058+
// whether the HTML block continues:
1059+
res = true;
1060+
} else if (S_last_line_blank(container)) {
1061+
// Establish the content indentation lazily so a blank line may follow
1062+
// the opening tag. A non-indented line still terminates the block:
1063+
if (container->as.html_block.indent == 0 && parser->indent > 0) {
1064+
container->as.html_block.indent = parser->indent;
1065+
}
1066+
1067+
res = container->as.html_block.indent > 0 &&
1068+
parser->indent >= container->as.html_block.indent;
1069+
} else {
1070+
// Record the shallowest positive content indentation before a blank
1071+
// line. Deeper nested HTML may then continue without changing it:
1072+
if (parser->indent > 0 &&
1073+
(container->as.html_block.indent == 0 ||
1074+
parser->indent < container->as.html_block.indent)) {
1075+
container->as.html_block.indent = parser->indent;
1076+
}
1077+
1078+
res = true;
1079+
}
10551080
break;
10561081
}
10571082

@@ -1224,7 +1249,7 @@ static void open_new_blocks(cmark_parser *parser, cmark_node **container,
12241249
input, parser->first_nonspace))))) {
12251250
*container = add_child(parser, *container, CMARK_NODE_HTML_BLOCK,
12261251
parser->first_nonspace + 1);
1227-
(*container)->as.html_block_type = matched;
1252+
(*container)->as.html_block.type = matched;
12281253
// note, we don't adjust parser->offset because the tag is part of the
12291254
// text
12301255
} else if (!indented && cont_type == CMARK_NODE_PARAGRAPH &&
@@ -1421,7 +1446,7 @@ static void add_text_to_container(cmark_parser *parser, cmark_node *container,
14211446
add_line(container, input, parser);
14221447

14231448
int matches_end_condition;
1424-
switch (container->as.html_block_type) {
1449+
switch (container->as.html_block.type) {
14251450
case 1:
14261451
// </script>, </style>, </pre>
14271452
matches_end_condition =

‎ext/markly/cmark-gfm.h‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,12 @@ char *cmark_render_latex_with_mem(cmark_node *root, int options, int width, cmar
798798
*/
799799
#define CMARK_OPT_INLINE_CODE_INFO (1 << 19)
800800

801+
/** Allow indented content in type 6 and 7 HTML blocks to continue across
802+
* blank lines. The indentation established by the HTML content must be
803+
* preserved after each blank line.
804+
*/
805+
#define CMARK_OPT_HTML_BLOCK_BLANK_LINES (1 << 20)
806+
801807
/**
802808
* ## Version information
803809
*/

‎ext/markly/node.h‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ typedef struct {
3333
int8_t fenced;
3434
} cmark_code;
3535

36+
typedef struct {
37+
int type;
38+
int indent;
39+
} cmark_html_block;
40+
3641
typedef struct {
3742
int level;
3843
bool setext;
@@ -104,7 +109,7 @@ struct cmark_node {
104109
cmark_heading heading;
105110
cmark_link link;
106111
cmark_custom custom;
107-
int html_block_type;
112+
cmark_html_block html_block;
108113
int cell_index; // For keeping track of TABLE_CELL table alignments
109114
void *opaque;
110115
} as;

‎lib/markly/flags.rb‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@ module Markly
2525
FRONT_MATTER = 1 << 18
2626
# Parse language prefixes on inline code spans, e.g. ruby:`Object.new`.
2727
INLINE_CODE_INFO = 1 << 19
28+
# Allow consistently indented HTML content to continue across blank lines.
29+
HTML_BLOCK_BLANK_LINES = 1 << 20
2830

2931
PARSE_FLAGS = {
3032
front_matter: FRONT_MATTER,
3133
inline_code_info: INLINE_CODE_INFO,
34+
html_block_blank_lines: HTML_BLOCK_BLANK_LINES,
3235
validate_utf8: VALIDATE_UTF8,
3336
smart_quotes: SMART,
3437
liberal_html_tags: LIBERAL_HTML_TAG,

‎releases.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Add `Markly::HTML_BLOCK_BLANK_LINES` for keeping consistently indented HTML content together across blank lines.
6+
37
## v0.18.0
48

59
- Preserve complete node and extension metadata when duplicating node trees.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "markly"
7+
8+
describe Markly::HTML_BLOCK_BLANK_LINES do
9+
let(:flags) {Markly::HTML_BLOCK_BLANK_LINES}
10+
11+
it "preserves CommonMark behavior by default" do
12+
markdown = "<div>\n\t<p>one</p>\n\n\t<p>two</p>\n</div>\n"
13+
document = Markly.parse(markdown)
14+
15+
expect(document.first_child.type).to be == :html
16+
expect(document.first_child.next).not.to be_nil
17+
end
18+
19+
it "keeps consistently indented HTML together across blank lines" do
20+
markdown = "<div>\n\t<p>one</p>\n\n <p>two</p>\n</div>\n"
21+
document = Markly.parse(markdown, flags: flags)
22+
23+
expect(document.first_child.type).to be == :html
24+
expect(document.first_child.string_content).to be == markdown
25+
expect(document.first_child.next).to be_nil
26+
end
27+
28+
it "can establish indentation after an initial blank line" do
29+
markdown = "<div>\n\n\t<p>content</p>\n</div>\n"
30+
document = Markly.parse(markdown, flags: flags)
31+
32+
expect(document.first_child.string_content).to be == markdown
33+
expect(document.first_child.next).to be_nil
34+
end
35+
36+
it "ends the HTML block when content dedents after a blank line" do
37+
markdown = "<div>\n\tcontent\n\noutside\n"
38+
document = Markly.parse(markdown, flags: flags)
39+
40+
expect(document.first_child.string_content).to be == "<div>\n\tcontent\n\n"
41+
expect(document.first_child.next.type).to be == :paragraph
42+
end
43+
44+
it "does not extend unindented HTML content" do
45+
markdown = "<div>\ncontent\n\nmore\n"
46+
document = Markly.parse(markdown, flags: flags)
47+
48+
expect(document.first_child.next).not.to be_nil
49+
end
50+
51+
it "renders retained raw HTML when unsafe rendering is enabled" do
52+
markdown = "<slide-diagram>\n first\n\n nested\n</slide-diagram>\n"
53+
html = Markly.render_html(
54+
markdown,
55+
parse_flags: flags,
56+
render_flags: Markly::UNSAFE,
57+
)
58+
59+
expect(html).to be == markdown
60+
end
61+
end

0 commit comments

Comments
 (0)