markbridge 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/markbridge/renderers/discourse/builders/list_item_builder.rb +15 -63
- data/lib/markbridge/renderers/discourse/html_block.rb +142 -0
- data/lib/markbridge/renderers/discourse/markdown_escaper.rb +20 -67
- data/lib/markbridge/renderers/discourse/renderer.rb +22 -3
- data/lib/markbridge/renderers/discourse/rendering_interface.rb +4 -2
- data/lib/markbridge/renderers/discourse/tags/align_tag.rb +24 -4
- data/lib/markbridge/renderers/discourse/tags/list_item_tag.rb +38 -33
- data/lib/markbridge/renderers/discourse.rb +1 -1
- data/lib/markbridge/rspec.rb +1 -1
- data/lib/markbridge/version.rb +1 -1
- metadata +2 -2
- data/lib/markbridge/renderers/discourse/html_block_safety.rb +0 -31
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa422d5e0a05eee7f32524e42823d02a552111132025fd9ede1fc5045a79412e
|
|
4
|
+
data.tar.gz: cc7af6319a7c9dde74a764dc9edaab186ef2cca92bc6fabd842a147216adaec0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2eb57814326cf0fbaf2e356290d962a0a9e8bf2b9c38aa4e41ad7526d9494574162fc5384473b15c833b4e34bd7f322bf464098337bf9d535662f615590c19e4
|
|
7
|
+
data.tar.gz: d4fc22c47cc54ab91a0d29b140bf69317c5c10aae3358758b0036b213f96ddca9318d723016854819a8a80dcf03ea5f66180eaccc8df2d550d06ab69b8f25a87
|
|
@@ -4,78 +4,30 @@ module Markbridge
|
|
|
4
4
|
module Renderers
|
|
5
5
|
module Discourse
|
|
6
6
|
module Builders
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# Formats one list item: the marker goes in front of the first
|
|
8
|
+
# line, every other line moves right by the width of the marker.
|
|
9
|
+
# The content is indented as one unit, so nested lists, code
|
|
10
|
+
# fences and HTML blocks inside it keep the shape they already
|
|
11
|
+
# have. Blank lines stay blank; the postprocessor clears
|
|
12
|
+
# whitespace-only lines anyway.
|
|
10
13
|
class ListItemBuilder
|
|
11
|
-
# Build a formatted list item string
|
|
12
14
|
# @param content [String] the item content
|
|
13
15
|
# @param marker [String] the list marker ("- " or "1. ")
|
|
14
|
-
# @param indent [String] the indentation string
|
|
15
16
|
# @return [String]
|
|
16
|
-
|
|
17
|
+
# @example
|
|
18
|
+
# builder.build("a\nb", marker: "- ") # => "- a\n b\n"
|
|
19
|
+
def build(content, marker:)
|
|
17
20
|
lines = content.split("\n")
|
|
18
|
-
first_line = "#{
|
|
19
|
-
|
|
21
|
+
first_line = "#{marker}#{lines.first}"
|
|
20
22
|
return "#{first_line}\n" if lines.size < 2
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
# Format multi-line content with proper indentation
|
|
28
|
-
# @param lines [Array<String>] content lines
|
|
29
|
-
# @param first_line [String] the formatted first line
|
|
30
|
-
# @param indent [String] base indentation
|
|
31
|
-
# @return [String]
|
|
32
|
-
def format_multiline(lines, first_line, indent)
|
|
33
|
-
continuation_indent = "#{indent} "
|
|
34
|
-
continuation_lines = lines[1..]
|
|
35
|
-
|
|
36
|
-
rest =
|
|
37
|
-
continuation_lines.each_with_index.filter_map do |line, idx|
|
|
38
|
-
format_continuation_line(line, idx, continuation_lines, continuation_indent)
|
|
39
|
-
end
|
|
40
|
-
|
|
24
|
+
indent = " " * marker.length
|
|
25
|
+
# An empty line maps to nil, and join turns that back into
|
|
26
|
+
# an empty line. Indenting it instead would leave a line
|
|
27
|
+
# with nothing but spaces on it.
|
|
28
|
+
rest = lines[1..].map { |line| "#{indent}#{line}" unless line.empty? }
|
|
41
29
|
"#{([first_line] + rest).join("\n")}\n"
|
|
42
30
|
end
|
|
43
|
-
|
|
44
|
-
# Format a single continuation line
|
|
45
|
-
# @param line [String] the line to format
|
|
46
|
-
# @param idx [Integer] index in continuation_lines array
|
|
47
|
-
# @param continuation_lines [Array<String>] all continuation lines
|
|
48
|
-
# @param continuation_indent [String] indent for continuation
|
|
49
|
-
# @return [String, nil] formatted line or nil to skip
|
|
50
|
-
def format_continuation_line(line, idx, continuation_lines, continuation_indent)
|
|
51
|
-
# Handle empty lines
|
|
52
|
-
return handle_empty_line(idx, continuation_lines, continuation_indent) if line.empty?
|
|
53
|
-
|
|
54
|
-
# Check if line is already a list item (has indentation + marker)
|
|
55
|
-
if line.match?(/\A\s*(?:-|\d+\.)\s/)
|
|
56
|
-
# Already a list item - don't add extra indentation
|
|
57
|
-
line
|
|
58
|
-
else
|
|
59
|
-
# Regular continuation line - add indentation
|
|
60
|
-
"#{continuation_indent}#{line}"
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# Handle empty lines in continuation. Caller (format_continuation_line)
|
|
65
|
-
# only invokes this when `line.empty?`, and `content.split("\n")`
|
|
66
|
-
# trims trailing empty strings, so the LAST continuation line is
|
|
67
|
-
# never empty — `idx + 1` is always in bounds when we get here.
|
|
68
|
-
# @param idx [Integer] index in continuation_lines
|
|
69
|
-
# @param continuation_lines [Array<String>] all continuation lines
|
|
70
|
-
# @param continuation_indent [String] indent for continuation
|
|
71
|
-
# @return [String, nil] formatted line or nil to skip
|
|
72
|
-
def handle_empty_line(idx, continuation_lines, continuation_indent)
|
|
73
|
-
# Skip empty lines that come before nested list items (structural blanks)
|
|
74
|
-
return nil if continuation_lines[idx + 1].match?(/\A\s*(?:-|\d+\.)\s/)
|
|
75
|
-
|
|
76
|
-
# Preserve empty lines within text content (paragraph breaks) with indentation
|
|
77
|
-
continuation_indent
|
|
78
|
-
end
|
|
79
31
|
end
|
|
80
32
|
end
|
|
81
33
|
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markbridge
|
|
4
|
+
module Renderers
|
|
5
|
+
module Discourse
|
|
6
|
+
# The one place that knows how CommonMark HTML blocks (spec §4.6)
|
|
7
|
+
# and Markdown meet in the rendered output.
|
|
8
|
+
#
|
|
9
|
+
# Inside an HTML block the content passes through as raw HTML.
|
|
10
|
+
# Markdown is parsed again only across blank lines. A tag that
|
|
11
|
+
# renders into such a block therefore picks one of two forms:
|
|
12
|
+
#
|
|
13
|
+
# 1. raw HTML — an HTML equivalent of the tag's Markdown, spliced
|
|
14
|
+
# into the block as it is;
|
|
15
|
+
# 2. a Markdown island — the tag's normal Markdown surrounded by
|
|
16
|
+
# blank lines, which end the block and start it again:
|
|
17
|
+
#
|
|
18
|
+
# <div align="center">
|
|
19
|
+
#
|
|
20
|
+
# [a link](https://example.com)
|
|
21
|
+
#
|
|
22
|
+
# </div>
|
|
23
|
+
module HtmlBlock
|
|
24
|
+
# Tag names that start a type 6 HTML block when a line begins
|
|
25
|
+
# with `<name` or `</name` followed by a space, a tab, `>`, `/>`
|
|
26
|
+
# or the end of the line.
|
|
27
|
+
BLOCK_TAGS = %w[
|
|
28
|
+
address
|
|
29
|
+
article
|
|
30
|
+
aside
|
|
31
|
+
base
|
|
32
|
+
basefont
|
|
33
|
+
blockquote
|
|
34
|
+
body
|
|
35
|
+
caption
|
|
36
|
+
center
|
|
37
|
+
col
|
|
38
|
+
colgroup
|
|
39
|
+
dd
|
|
40
|
+
details
|
|
41
|
+
dialog
|
|
42
|
+
dir
|
|
43
|
+
div
|
|
44
|
+
dl
|
|
45
|
+
dt
|
|
46
|
+
fieldset
|
|
47
|
+
figcaption
|
|
48
|
+
figure
|
|
49
|
+
footer
|
|
50
|
+
form
|
|
51
|
+
frame
|
|
52
|
+
frameset
|
|
53
|
+
h1
|
|
54
|
+
h2
|
|
55
|
+
h3
|
|
56
|
+
h4
|
|
57
|
+
h5
|
|
58
|
+
h6
|
|
59
|
+
head
|
|
60
|
+
header
|
|
61
|
+
hr
|
|
62
|
+
html
|
|
63
|
+
iframe
|
|
64
|
+
legend
|
|
65
|
+
li
|
|
66
|
+
link
|
|
67
|
+
main
|
|
68
|
+
menu
|
|
69
|
+
menuitem
|
|
70
|
+
nav
|
|
71
|
+
noframes
|
|
72
|
+
ol
|
|
73
|
+
optgroup
|
|
74
|
+
option
|
|
75
|
+
p
|
|
76
|
+
param
|
|
77
|
+
search
|
|
78
|
+
section
|
|
79
|
+
summary
|
|
80
|
+
table
|
|
81
|
+
tbody
|
|
82
|
+
td
|
|
83
|
+
tfoot
|
|
84
|
+
th
|
|
85
|
+
thead
|
|
86
|
+
title
|
|
87
|
+
tr
|
|
88
|
+
track
|
|
89
|
+
ul
|
|
90
|
+
].freeze
|
|
91
|
+
|
|
92
|
+
# The spec allows up to three leading spaces. We match any amount
|
|
93
|
+
# of leading whitespace instead, because the lines this runs on
|
|
94
|
+
# are item content that the list builder has not indented yet.
|
|
95
|
+
OPENER = %r{\A[ \t]*</?(?:#{Regexp.union(BLOCK_TAGS).source})(?:[ \t>]|/>|\z)}i
|
|
96
|
+
|
|
97
|
+
# Markdown sigils that would surface as literal text inside an
|
|
98
|
+
# HTML block: emphasis (`*`, `_`, `~`) and link middles (`](`).
|
|
99
|
+
MARKDOWN_SIGILS = /[*_~]|\]\(/
|
|
100
|
+
|
|
101
|
+
# Blank lines at the start or at the end of a fragment. The first
|
|
102
|
+
# content line keeps its own indentation, e.g. a nested ` - a`.
|
|
103
|
+
BLANK_EDGES = /\A(?:[ \t]*\n)+|(?:\n[ \t]*)+\z/
|
|
104
|
+
|
|
105
|
+
private_constant :BLOCK_TAGS, :OPENER, :MARKDOWN_SIGILS, :BLANK_EDGES
|
|
106
|
+
|
|
107
|
+
# Whether +line+ starts an HTML block. A closing tag such as
|
|
108
|
+
# `</div>` starts one too. The block runs until the next blank
|
|
109
|
+
# line.
|
|
110
|
+
# @param line [String]
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def self.opens?(line)
|
|
113
|
+
OPENER.match?(line)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Wrap Markdown so that CommonMark parses it even inside an HTML
|
|
117
|
+
# block. Blank lines that the fragment already has at its edges
|
|
118
|
+
# are folded into the wrap.
|
|
119
|
+
# @param markdown [String]
|
|
120
|
+
# @return [String]
|
|
121
|
+
# @example
|
|
122
|
+
# HtmlBlock.island("- a\n") # => "\n\n- a\n\n"
|
|
123
|
+
def self.island(markdown)
|
|
124
|
+
"\n\n#{markdown.gsub(BLANK_EDGES, "")}\n\n"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Whether a fragment can be spliced into an HTML block: raw HTML
|
|
128
|
+
# or plain text without Markdown sigils, or an island.
|
|
129
|
+
#
|
|
130
|
+
# Used by the html_mode contract check that ships in
|
|
131
|
+
# +markbridge/rspec+ and by this repo's own contract spec.
|
|
132
|
+
# @param output [String] a tag's html_mode render result
|
|
133
|
+
# @return [Boolean]
|
|
134
|
+
def self.safe?(output)
|
|
135
|
+
return true if output.start_with?("\n\n") && output.end_with?("\n\n")
|
|
136
|
+
|
|
137
|
+
!output.match?(MARKDOWN_SIGILS)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -76,7 +76,6 @@ module Markbridge
|
|
|
76
76
|
FENCED_CODE_BACKTICK = /\A`{3,}[^`]*$/
|
|
77
77
|
FENCED_CODE_TILDE = /\A~{3,}/
|
|
78
78
|
SETEXT_UNDERLINE_EQUALS = /\A=+[ \t]*$/
|
|
79
|
-
SETEXT_UNDERLINE_DASH = /\A-+[ \t]*$/
|
|
80
79
|
# Indented code: 4+ spaces, tab at start, or space+tab reaching column 4+
|
|
81
80
|
INDENTED_CODE = /\A(?: {4}|\t| {1,3}\t)/
|
|
82
81
|
|
|
@@ -182,11 +181,11 @@ module Markbridge
|
|
|
182
181
|
# skip the split and its Array + line-String allocations. A lone
|
|
183
182
|
# `\r` without `\n` stays on the line either way — `/\r?\n/`
|
|
184
183
|
# needs the `\n` — so `include?("\n")` alone decides correctly.
|
|
185
|
-
return escape_line(text
|
|
184
|
+
return escape_line(text) unless text.include?("\n")
|
|
186
185
|
|
|
187
186
|
# On CRLF input, consume `\r` as part of the line terminator instead
|
|
188
187
|
# of leaving it on the line. A trailing `\r` breaks line-end anchored
|
|
189
|
-
# regexes (e.g.
|
|
188
|
+
# regexes (e.g. SETEXT_UNDERLINE_EQUALS) and the `ws_end >= line_length`
|
|
190
189
|
# early-out in escape_indented_code, leaking NBSPs onto
|
|
191
190
|
# whitespace-only CRLF lines. The `include?` guard keeps the
|
|
192
191
|
# LF-only fast path on a string split (regex split is ~20% slower
|
|
@@ -196,22 +195,19 @@ module Markbridge
|
|
|
196
195
|
# Pre-allocate result buffer
|
|
197
196
|
bytesize = text.bytesize
|
|
198
197
|
result = String.new(capacity: bytesize + bytesize / 3, encoding: text.encoding)
|
|
199
|
-
prev_was_paragraph = false
|
|
200
198
|
first = true
|
|
201
199
|
|
|
202
200
|
lines.each do |line|
|
|
203
201
|
result << "\n" unless first
|
|
204
202
|
first = false
|
|
205
203
|
|
|
206
|
-
|
|
207
|
-
result << escaped
|
|
208
|
-
prev_was_paragraph = paragraph_line?(line)
|
|
204
|
+
result << escape_line(line)
|
|
209
205
|
end
|
|
210
206
|
|
|
211
207
|
result
|
|
212
208
|
end
|
|
213
209
|
|
|
214
|
-
def escape_line(line
|
|
210
|
+
def escape_line(line)
|
|
215
211
|
# No `line.empty?` early-return: it's redundant with the
|
|
216
212
|
# `line.getbyte(indent_len).nil?` guard below, which catches both
|
|
217
213
|
# empty and whitespace-only lines while also preserving object
|
|
@@ -229,7 +225,7 @@ module Markbridge
|
|
|
229
225
|
has_indent = indent_len > 0
|
|
230
226
|
content = has_indent ? line[indent_len..] : line
|
|
231
227
|
|
|
232
|
-
escaped, skip_inline = escape_block_level(content
|
|
228
|
+
escaped, skip_inline = escape_block_level(content)
|
|
233
229
|
escaped = escape_inline(escaped) unless skip_inline
|
|
234
230
|
|
|
235
231
|
if has_indent
|
|
@@ -276,7 +272,7 @@ module Markbridge
|
|
|
276
272
|
"#{nbsp_indent}#{escape_inline(content)}"
|
|
277
273
|
end
|
|
278
274
|
|
|
279
|
-
def escape_block_level(content
|
|
275
|
+
def escape_block_level(content)
|
|
280
276
|
first_byte = content.getbyte(0)
|
|
281
277
|
|
|
282
278
|
case first_byte
|
|
@@ -289,7 +285,7 @@ module Markbridge
|
|
|
289
285
|
return pass_first_char_inline(content) if @allow.include?(:block_quote)
|
|
290
286
|
return escape_first_char_inline(content, "\\>")
|
|
291
287
|
when DASH
|
|
292
|
-
return escape_block_dash(content
|
|
288
|
+
return escape_block_dash(content)
|
|
293
289
|
when PLUS
|
|
294
290
|
if BULLET_LIST.match?(content)
|
|
295
291
|
return pass_first_char_inline(content) if @allow.include?(:bullet_list)
|
|
@@ -302,7 +298,17 @@ module Markbridge
|
|
|
302
298
|
return escape_all_chars(content, UNDERSCORE, "\\_"), true
|
|
303
299
|
end
|
|
304
300
|
when EQUALS
|
|
305
|
-
|
|
301
|
+
# A line of only `=` is a setext heading underline when a
|
|
302
|
+
# paragraph line comes before it. The escaper sees a single
|
|
303
|
+
# text fragment, and the renderer can put that fragment
|
|
304
|
+
# right after a paragraph line — after a line break, or
|
|
305
|
+
# after inline markup like `[b]Body[/b]\n===` — so the
|
|
306
|
+
# previous line is not visible here. The line is therefore
|
|
307
|
+
# escaped in every position, like every other block
|
|
308
|
+
# construct. Where no paragraph line comes before it,
|
|
309
|
+
# Discourse renders `\=` as a literal `=`, so the result
|
|
310
|
+
# looks the same.
|
|
311
|
+
if SETEXT_UNDERLINE_EQUALS.match?(content)
|
|
306
312
|
return escape_all_chars(content, EQUALS, "\\="), true
|
|
307
313
|
end
|
|
308
314
|
when BACKTICK
|
|
@@ -327,11 +333,8 @@ module Markbridge
|
|
|
327
333
|
["#{escaped_char}#{escape_inline(content[1..])}", true]
|
|
328
334
|
end
|
|
329
335
|
|
|
330
|
-
def escape_block_dash(content
|
|
331
|
-
if THEMATIC_BREAK_DASH.match?(content)
|
|
332
|
-
(prev_was_paragraph && SETEXT_UNDERLINE_DASH.match?(content))
|
|
333
|
-
return escape_all_chars(content, DASH, "\\-"), true
|
|
334
|
-
end
|
|
336
|
+
def escape_block_dash(content)
|
|
337
|
+
return escape_all_chars(content, DASH, "\\-"), true if THEMATIC_BREAK_DASH.match?(content)
|
|
335
338
|
if BULLET_LIST.match?(content)
|
|
336
339
|
return pass_first_char_inline(content) if @allow.include?(:bullet_list)
|
|
337
340
|
return escape_first_char_inline(content, "\\-")
|
|
@@ -562,56 +565,6 @@ module Markbridge
|
|
|
562
565
|
1
|
|
563
566
|
end
|
|
564
567
|
end
|
|
565
|
-
|
|
566
|
-
def paragraph_line?(line)
|
|
567
|
-
pos = 0
|
|
568
|
-
line_len = line.bytesize
|
|
569
|
-
pos += 1 while pos < line_len && line.getbyte(pos) == SPACE
|
|
570
|
-
first_non_space = pos
|
|
571
|
-
|
|
572
|
-
# Empty or whitespace-only lines: getbyte past the end returns nil.
|
|
573
|
-
return false if line.getbyte(first_non_space).nil?
|
|
574
|
-
|
|
575
|
-
# Indented code (4+ spaces or any leading \t) is not a paragraph.
|
|
576
|
-
# INDENTED_CODE also catches lines where first_non_space > 3, so no
|
|
577
|
-
# separate numeric boundary check is needed.
|
|
578
|
-
return false if INDENTED_CODE.match?(line)
|
|
579
|
-
|
|
580
|
-
content = first_non_space == 0 ? line : line[first_non_space..]
|
|
581
|
-
|
|
582
|
-
# Lines starting with [ are paragraph content (the escaper rewrites [
|
|
583
|
-
# to \[). block_construct? has no BRACKET_OPEN case arm, so such
|
|
584
|
-
# lines naturally fall through and !block_construct?(content) == true.
|
|
585
|
-
!block_construct?(content)
|
|
586
|
-
end
|
|
587
|
-
|
|
588
|
-
# Checks whether content starts with a block-level markdown construct.
|
|
589
|
-
# Used by both escape_block_level (to decide what to escape) and
|
|
590
|
-
# paragraph_line? (to decide if setext underlines can follow).
|
|
591
|
-
def block_construct?(content)
|
|
592
|
-
case content.getbyte(0)
|
|
593
|
-
when HASH
|
|
594
|
-
ATX_HEADING.match?(content)
|
|
595
|
-
when GT
|
|
596
|
-
true
|
|
597
|
-
when DASH
|
|
598
|
-
BULLET_LIST.match?(content) || THEMATIC_BREAK_DASH.match?(content)
|
|
599
|
-
when STAR
|
|
600
|
-
BULLET_LIST.match?(content) || THEMATIC_BREAK_STAR.match?(content)
|
|
601
|
-
when PLUS
|
|
602
|
-
BULLET_LIST.match?(content)
|
|
603
|
-
when UNDERSCORE
|
|
604
|
-
THEMATIC_BREAK_UNDERSCORE.match?(content)
|
|
605
|
-
when BACKTICK
|
|
606
|
-
FENCED_CODE_BACKTICK.match?(content)
|
|
607
|
-
when TILDE
|
|
608
|
-
FENCED_CODE_TILDE.match?(content)
|
|
609
|
-
when DIGIT_0..DIGIT_9
|
|
610
|
-
ORDERED_LIST.match?(content)
|
|
611
|
-
else
|
|
612
|
-
false
|
|
613
|
-
end
|
|
614
|
-
end
|
|
615
568
|
end
|
|
616
569
|
end
|
|
617
570
|
end
|
|
@@ -87,19 +87,38 @@ module Markbridge
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
# Render all children of a node
|
|
90
|
+
#
|
|
91
|
+
# With a block, the block runs at every join point, right before
|
|
92
|
+
# a non-empty child output is appended. It receives the buffer
|
|
93
|
+
# built so far and the child about to be rendered into it, and
|
|
94
|
+
# may change the buffer in place. A tag uses this to adjust the
|
|
95
|
+
# text in front of a specific child without iterating the
|
|
96
|
+
# children itself, which would lose the emphasis-boundary rule
|
|
97
|
+
# below.
|
|
98
|
+
#
|
|
90
99
|
# @param node [AST::Element]
|
|
91
100
|
# @param context [RenderContext] rendering context
|
|
101
|
+
# @yieldparam result [String] the buffer built so far
|
|
102
|
+
# @yieldparam child [AST::Node] the child about to be appended
|
|
92
103
|
# @return [String]
|
|
104
|
+
# @example Attach a nested list directly to the text in front of it
|
|
105
|
+
# interface.render_children(item, context:) do |buffer, child|
|
|
106
|
+
# buffer.rstrip! if child.is_a?(AST::List)
|
|
107
|
+
# end
|
|
93
108
|
def render_children(node, context:)
|
|
94
109
|
result = +""
|
|
95
110
|
node.children.each do |child|
|
|
96
111
|
part = render(child, context:)
|
|
97
112
|
next if part.empty?
|
|
98
113
|
|
|
114
|
+
yield(result, child) if block_given?
|
|
115
|
+
|
|
99
116
|
# Integer-byte check avoids allocating substrings for the
|
|
100
117
|
# per-child adjacency probe. EMPHASIS_DELIMITER_BYTES.include?
|
|
101
|
-
# over a 4-element Set is O(1).
|
|
102
|
-
|
|
118
|
+
# over a 4-element Set is O(1). On an empty buffer getbyte
|
|
119
|
+
# returns nil, which matches no byte of a non-empty part, so
|
|
120
|
+
# the first child needs no extra guard.
|
|
121
|
+
if (last_byte = result.getbyte(-1)) == part.getbyte(0) &&
|
|
103
122
|
EMPHASIS_DELIMITER_BYTES.include?(last_byte)
|
|
104
123
|
result << EMPHASIS_BOUNDARY
|
|
105
124
|
end
|
|
@@ -168,7 +187,7 @@ module Markbridge
|
|
|
168
187
|
# parses the content as Markdown before the closing tags reopen another
|
|
169
188
|
# HTML block.
|
|
170
189
|
def render_markdown_text(node, context)
|
|
171
|
-
context.html_mode? ?
|
|
190
|
+
context.html_mode? ? HtmlBlock.island(node.text) : node.text
|
|
172
191
|
end
|
|
173
192
|
|
|
174
193
|
def render_text(node, context)
|
|
@@ -26,8 +26,10 @@ module Markbridge
|
|
|
26
26
|
@renderer.render_default(node, context:)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
# An optional block runs at every join point, before a non-empty
|
|
30
|
+
# child output is appended — see Renderer#render_children.
|
|
31
|
+
def render_children(element, context: @context, &block)
|
|
32
|
+
@renderer.render_children(element, context:, &block)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# Context operations
|
|
@@ -20,10 +20,30 @@ module Markbridge
|
|
|
20
20
|
|
|
21
21
|
return content unless ALLOWED_ALIGNMENTS.include?(element.alignment)
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
#
|
|
26
|
-
|
|
23
|
+
return html_block_form(element, content) if interface.html_mode?
|
|
24
|
+
|
|
25
|
+
# Keeps consecutive aligned blocks from merging.
|
|
26
|
+
"\n\n#{markdown_island_form(element, content)}\n\n"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Children already render as raw HTML here, and a blank line
|
|
32
|
+
# would terminate the enclosing block (e.g. a <table>).
|
|
33
|
+
def html_block_form(element, content)
|
|
34
|
+
%(<div align="#{element.alignment}">#{content}</div>)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A `<div>` opens an HTML block (CommonMark §4.6), and Markdown
|
|
38
|
+
# inside one is only parsed across blank lines. Without them a
|
|
39
|
+
# link in the content shows up as its own source text. The blank
|
|
40
|
+
# lines do mean CommonMark wraps the content in a `<p>`, so
|
|
41
|
+
# inline content picks up a paragraph margin.
|
|
42
|
+
def markdown_island_form(element, content)
|
|
43
|
+
open_tag = %(<div align="#{element.alignment}">)
|
|
44
|
+
return "#{open_tag}</div>" if content.match?(/\A\s*\z/)
|
|
45
|
+
|
|
46
|
+
"#{open_tag}#{HtmlBlock.island(content)}</div>"
|
|
27
47
|
end
|
|
28
48
|
end
|
|
29
49
|
end
|
|
@@ -4,7 +4,13 @@ module Markbridge
|
|
|
4
4
|
module Renderers
|
|
5
5
|
module Discourse
|
|
6
6
|
module Tags
|
|
7
|
-
# Tag for rendering list items
|
|
7
|
+
# Tag for rendering list items.
|
|
8
|
+
#
|
|
9
|
+
# An item does not indent itself. It renders its content at
|
|
10
|
+
# column zero and hands it to the builder, which moves the whole
|
|
11
|
+
# content right by the width of the marker. A nested list then
|
|
12
|
+
# lands at the content column of the item that holds it, whatever
|
|
13
|
+
# the nesting depth is.
|
|
8
14
|
class ListItemTag < Tag
|
|
9
15
|
def initialize
|
|
10
16
|
@builder = Builders::ListItemBuilder.new
|
|
@@ -12,50 +18,49 @@ module Markbridge
|
|
|
12
18
|
|
|
13
19
|
def render(element, interface)
|
|
14
20
|
child_context = interface.with_parent(element)
|
|
15
|
-
|
|
16
|
-
return "" if content.empty?
|
|
21
|
+
return render_html(element, interface, child_context) if interface.html_mode?
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
content = render_content(element, interface, child_context)
|
|
24
|
+
return "" if content.empty?
|
|
19
25
|
|
|
20
26
|
parent_list = interface.find_parent(AST::List)
|
|
21
|
-
@builder.build(
|
|
22
|
-
content,
|
|
23
|
-
marker: determine_marker(parent_list),
|
|
24
|
-
indent: calculate_indent(interface),
|
|
25
|
-
)
|
|
27
|
+
@builder.build(content, marker: determine_marker(parent_list))
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
private
|
|
29
31
|
|
|
30
|
-
# @param parent_list [AST::List, nil]
|
|
31
32
|
# @return [String]
|
|
32
|
-
def
|
|
33
|
-
|
|
33
|
+
def render_html(element, interface, child_context)
|
|
34
|
+
content = interface.render_children(element, context: child_context).strip
|
|
35
|
+
content.empty? ? "" : "<li>#{content}</li>"
|
|
34
36
|
end
|
|
35
37
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
38
|
+
# A nested list is attached directly to the content in front of
|
|
39
|
+
# it. A blank line there would make the whole list loose, and
|
|
40
|
+
# CommonMark then wraps every item in a <p>. Blank lines inside
|
|
41
|
+
# the output of a child are never touched.
|
|
40
42
|
# @return [String]
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
def render_content(element, interface, child_context)
|
|
44
|
+
content =
|
|
45
|
+
interface.render_children(element, context: child_context) do |buffer, child|
|
|
46
|
+
tighten(buffer) if child.is_a?(AST::List)
|
|
47
|
+
end
|
|
48
|
+
content.strip
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Drops the blank lines in front of a nested list, but keeps one
|
|
52
|
+
# when the line before it opens an HTML block (for example a
|
|
53
|
+
# `</table>`), because only a blank line ends such a block.
|
|
54
|
+
def tighten(buffer)
|
|
55
|
+
buffer.rstrip!
|
|
56
|
+
last_line = buffer[(buffer.rindex("\n") || -1) + 1..]
|
|
57
|
+
buffer << "\n" if HtmlBlock.opens?(last_line)
|
|
58
|
+
end
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
found += 1
|
|
52
|
-
# The immediate parent List is the LAST one in parents; its
|
|
53
|
-
# marker width is already accounted for by the item's own
|
|
54
|
-
# marker, so it doesn't contribute to indent.
|
|
55
|
-
break if found == list_count
|
|
56
|
-
indent << (parent.ordered? ? " " : " ")
|
|
57
|
-
end
|
|
58
|
-
indent
|
|
60
|
+
# @param parent_list [AST::List, nil]
|
|
61
|
+
# @return [String]
|
|
62
|
+
def determine_marker(parent_list)
|
|
63
|
+
parent_list&.ordered? ? "1. " : "- "
|
|
59
64
|
end
|
|
60
65
|
end
|
|
61
66
|
end
|
|
@@ -7,7 +7,7 @@ require_relative "discourse/rendering_interface"
|
|
|
7
7
|
require_relative "discourse/markdown_escaper"
|
|
8
8
|
require_relative "discourse/identity_escaper"
|
|
9
9
|
require_relative "discourse/html_escaper"
|
|
10
|
-
require_relative "discourse/
|
|
10
|
+
require_relative "discourse/html_block"
|
|
11
11
|
require_relative "discourse/postprocessor"
|
|
12
12
|
|
|
13
13
|
# Builders
|
data/lib/markbridge/rspec.rb
CHANGED
|
@@ -39,7 +39,7 @@ RSpec.shared_examples "an html_mode safe tag" do
|
|
|
39
39
|
it "renders html_mode output that is safe inside an HTML block" do
|
|
40
40
|
output = tag.render(element, markbridge_html_mode_interface)
|
|
41
41
|
|
|
42
|
-
expect(Markbridge::Renderers::Discourse::
|
|
42
|
+
expect(Markbridge::Renderers::Discourse::HtmlBlock.safe?(output)).to be(true),
|
|
43
43
|
"Expected #{tag.class} to render raw HTML or a \\n\\n-wrapped " \
|
|
44
44
|
"Markdown island in html_mode, got: #{output.inspect}"
|
|
45
45
|
end
|
data/lib/markbridge/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: markbridge
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Discourse Team
|
|
@@ -138,7 +138,7 @@ files:
|
|
|
138
138
|
- lib/markbridge/parsers/text_formatter/parser.rb
|
|
139
139
|
- lib/markbridge/renderers/discourse.rb
|
|
140
140
|
- lib/markbridge/renderers/discourse/builders/list_item_builder.rb
|
|
141
|
-
- lib/markbridge/renderers/discourse/
|
|
141
|
+
- lib/markbridge/renderers/discourse/html_block.rb
|
|
142
142
|
- lib/markbridge/renderers/discourse/html_escaper.rb
|
|
143
143
|
- lib/markbridge/renderers/discourse/identity_escaper.rb
|
|
144
144
|
- lib/markbridge/renderers/discourse/markdown_escaper.rb
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Markbridge
|
|
4
|
-
module Renderers
|
|
5
|
-
module Discourse
|
|
6
|
-
# Decides whether a rendered fragment is safe to splice into a
|
|
7
|
-
# CommonMark HTML block (spec §4.6). Inside such a block the
|
|
8
|
-
# content passes through as raw HTML; Markdown is only parsed
|
|
9
|
-
# again across blank lines. Safe output is therefore: a raw HTML
|
|
10
|
-
# or plain-text fragment without Markdown sigils, or a
|
|
11
|
-
# +\n\n…\n\n+ wrap — a deliberate Markdown island.
|
|
12
|
-
#
|
|
13
|
-
# Used by the html_mode contract check that ships in
|
|
14
|
-
# +markbridge/rspec+ and by this repo's own contract spec.
|
|
15
|
-
module HtmlBlockSafety
|
|
16
|
-
# Markdown sigils that would surface as literal text inside an
|
|
17
|
-
# HTML block: emphasis (`*`, `_`, `~`) and link middles (`](`).
|
|
18
|
-
MARKDOWN_SIGILS = /[*_~]|\]\(/
|
|
19
|
-
private_constant :MARKDOWN_SIGILS
|
|
20
|
-
|
|
21
|
-
# @param output [String] a tag's html_mode render result
|
|
22
|
-
# @return [Boolean]
|
|
23
|
-
def self.safe?(output)
|
|
24
|
-
return true if output.start_with?("\n\n") && output.end_with?("\n\n")
|
|
25
|
-
|
|
26
|
-
!output.match?(MARKDOWN_SIGILS)
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|