mmmd 0.1.0

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.
@@ -0,0 +1,452 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Attempt to source a provider for the wide char width calculator
4
+ # (TODO)
5
+
6
+ module MMMD
7
+ # Module for managing terminal output
8
+ module TextManager
9
+ # ANSI SGR escape code for bg color
10
+ # @param text [String]
11
+ # @param options [Hash]
12
+ # @return [String]
13
+ def bg(text, options)
14
+ color = options['bg']
15
+ if color.is_a? Integer
16
+ "\e[48;5;#{color}m#{text}\e[49m"
17
+ elsif color.is_a? String and color.match?(/\A#[A-Fa-f0-9]{6}\Z/)
18
+ vector = color.scan(/[A-Fa-f0-9]{2}/).map { |x| x.to_i(16) }
19
+ "\e[48;2;#{vector[0]};#{vector[1]};#{vector[2]}\e[49m"
20
+ else
21
+ Kernel.warn "WARNING: Invalid color - #{color}"
22
+ text
23
+ end
24
+ end
25
+
26
+ # ANSI SGR escape code for fg color
27
+ # @param text [String]
28
+ # @param options [Hash]
29
+ # @return [String]
30
+ def fg(text, options)
31
+ color = options['fg']
32
+ if color.is_a? Integer
33
+ "\e[38;5;#{color}m#{text}\e[39m"
34
+ elsif color.is_a? String and color.match?(/\A#[A-Fa-f0-9]{6}\Z/)
35
+ vector = color.scan(/[A-Fa-f0-9]{2}/).map { |x| x.to_i(16) }
36
+ "\e[38;2;#{vector[0]};#{vector[1]};#{vector[2]}\e[39m"
37
+ else
38
+ Kernel.warn "WARNING: Invalid color - #{color}"
39
+ text
40
+ end
41
+ end
42
+
43
+ # ANSI SGR escape code for bold text
44
+ # @param text [String]
45
+ # @param options [Hash]
46
+ # @return [String]
47
+ def bold(text, _options)
48
+ "\e[1m#{text}\e[22m"
49
+ end
50
+
51
+ # ANSI SGR escape code for italics text
52
+ # @param text [String]
53
+ # @param options [Hash]
54
+ # @return [String]
55
+ def italics(text, _options)
56
+ "\e[3m#{text}\e[23m"
57
+ end
58
+
59
+ # ANSI SGR escape code for underline text
60
+ # @param text [String]
61
+ # @param options [Hash]
62
+ # @return [String]
63
+ def underline(text, _options)
64
+ "\e[4m#{text}\e[24m"
65
+ end
66
+
67
+ # ANSI SGR escape code for strikethrough text
68
+ # @param text [String]
69
+ # @param options [Hash]
70
+ # @return [String]
71
+ def strikethrough(text, _options)
72
+ "\e[9m#{text}\e[29m"
73
+ end
74
+
75
+ # Word wrapping algorithm
76
+ # @param text [String]
77
+ # @param width [Integer]
78
+ # @return [String]
79
+ def wordwrap(text, width)
80
+ words = text.split(/( +)/)
81
+ output = []
82
+ line = ""
83
+ length = 0
84
+ until words.empty?
85
+ word = words.shift
86
+ wordlength = smort_length(word)
87
+ if wordlength > width
88
+ words.prepend(word[width..])
89
+ word = word[..width - 1]
90
+ end
91
+ if length + wordlength + 1 > width
92
+ output.append(line.lstrip)
93
+ line = word
94
+ length = wordlength
95
+ next
96
+ end
97
+ length += wordlength
98
+ line += word
99
+ end
100
+ output.append(line.lstrip)
101
+ output.join("\n")
102
+ end
103
+
104
+ # (TODO: smorter stronger better faster)
105
+ # SmЯt™ word length
106
+ # @param text [String]
107
+ # @return [Integer]
108
+ def smort_length(text)
109
+ text.gsub(/\e\[[^m]+m/, '').length
110
+ end
111
+
112
+ # Left-justify a line while ignoring terminal control codes
113
+ # @param text [String]
114
+ # @param size [Integer]
115
+ # @return [String]
116
+ def ljust_cc(text, size)
117
+ text.lines.map do |line|
118
+ textlength = smort_length(line)
119
+ textlength < size ? line + " " * (size - textlength) : line
120
+ end.join("\n")
121
+ end
122
+
123
+ # Right-justify a line while ignoring terminal control codes
124
+ # @param text [String]
125
+ # @param size [Integer]
126
+ # @return [String]
127
+ def rjust_cc(text, size)
128
+ text.lines.map do |line|
129
+ textlength = smort_length(line)
130
+ textlength < size ? " " * (size - textlength) + line : line
131
+ end.join("\n")
132
+ end
133
+
134
+ # Center-justify a line while ignoring terminal control codes
135
+ # @param text [String]
136
+ # @param size [Integer]
137
+ # @return [String]
138
+ def center_cc(text, size)
139
+ text.lines.map do |line|
140
+ textlength = smort_length(line)
141
+ if textlength < size
142
+ freelength = size - textlength
143
+ rightlength = freelength / 2
144
+ leftlength = freelength - rightlength
145
+ " " * leftlength + line + " " * rightlength
146
+ else
147
+ line
148
+ end
149
+ end.join("\n")
150
+ end
151
+
152
+ # Draw a screen-width box around text
153
+ # @param text [String]
154
+ # @param options [Hash]
155
+ # @return [String]
156
+ def box(text, options)
157
+ size = options[:hsize] - 2
158
+ text = wordwrap(text, (size * 0.8).floor).lines.filter_map do |line|
159
+ "│#{ljust_cc(line, size)}│" unless line.empty?
160
+ end.join("\n")
161
+ <<~TEXT
162
+ ╭#{'─' * size}╮
163
+ #{text}
164
+ ╰#{'─' * size}╯
165
+ TEXT
166
+ end
167
+
168
+ # Draw text right-justified
169
+ def rjust(text, options)
170
+ size = options[:hsize]
171
+ wordwrap(text, (size * 0.8).floor).lines.filter_map do |line|
172
+ rjust_cc(line, size) unless line.empty?
173
+ end.join("\n")
174
+ end
175
+
176
+ # Draw text centered
177
+ def center(text, options)
178
+ size = options[:hsize]
179
+ wordwrap(text, (size * 0.8).floor).lines.filter_map do |line|
180
+ center_cc(line, size) unless line.empty?
181
+ end.join("\n")
182
+ end
183
+
184
+ # Underline the last line of the text piece
185
+ def underline_block(text, options)
186
+ textlines = text.lines
187
+ last = "".match(/()()()/)
188
+ textlines.each do |x|
189
+ current = x.match(/\A(\s*)(.+?)(\s*)\Z/)
190
+ last = current if smort_length(current[2]) > smort_length(last[2])
191
+ end
192
+ ltxt = last[1]
193
+ ctxt = textlines.last.slice(last.offset(2)[0]..last.offset(2)[1] - 1)
194
+ rtxt = last[3]
195
+ textlines[-1] = [ltxt, underline(ctxt, options), rtxt].join('')
196
+ textlines.join("")
197
+ end
198
+
199
+ # Add extra newlines around the text
200
+ def extra_newlines(text, options)
201
+ size = options[:hsize]
202
+ textlines = text.lines
203
+ textlines.prepend("#{' ' * size}\n")
204
+ textlines.append("\n#{' ' * size}\n")
205
+ textlines.join("")
206
+ end
207
+
208
+ # Underline last line edge to edge
209
+ def underline_full_block(text, options)
210
+ textlines = text.lines
211
+ last_line = textlines.last.match(/^.*$/)[0]
212
+ textlines[-1] = "#{underline(last_line, options)}\n"
213
+ textlines.join("")
214
+ end
215
+
216
+ # Indent all lines
217
+ def indent(text, _options)
218
+ _indent(text)
219
+ end
220
+
221
+ # Indent all lines (inner)
222
+ def _indent(text)
223
+ text.lines.map do |line|
224
+ " #{line}"
225
+ end.join("")
226
+ end
227
+
228
+ # Left overline all lines
229
+ def leftline(text, _options)
230
+ text.lines.map do |line|
231
+ " │ #{line}"
232
+ end.join("")
233
+ end
234
+
235
+ # Bulletpoints
236
+ def bullet(text, _options)
237
+ "-#{_indent(text)[1..]}"
238
+ end
239
+
240
+ # Numbers
241
+ def numbered(text, options)
242
+ number = options[:number]
243
+ length = number.to_s.length + 1
244
+ (length / 4 + 1).times { text = _indent(text) }
245
+ "#{number}.#{text[length..]}"
246
+ end
247
+ end
248
+
249
+ module Renderers
250
+ module PlaintermConstants
251
+ DEFAULT_STYLE = {
252
+ "PointBlank::DOM::Paragraph" => {
253
+ indent: true,
254
+ increase_level: true
255
+ },
256
+ "PointBlank::DOM::Text" => {},
257
+ "PointBlank::DOM::SetextHeading1" => {
258
+ center: true,
259
+ bold: true,
260
+ extra_newlines: true,
261
+ underline_full_block: true
262
+ },
263
+ "PointBlank::DOM::SetextHeading2" => {
264
+ center: true,
265
+ underline_block: true
266
+ },
267
+ "PointBlank::DOM::ATXHeading1" => {
268
+ center: true,
269
+ bold: true,
270
+ extra_newlines: true,
271
+ underline_full_block: true
272
+ },
273
+ "PointBlank::DOM::ATXHeading2" => {
274
+ center: true,
275
+ underline_block: true
276
+ },
277
+ "PointBlank::DOM::ATXHeading3" => {
278
+ underline: true,
279
+ bold: true
280
+ },
281
+ "PointBlank::DOM::ATXHeading4" => {
282
+ bold: true,
283
+ underline: true
284
+ },
285
+ "PointBlank::DOM::ATXHeading5" => {
286
+ underline: true
287
+ },
288
+ "PointBlank::DOM::ATXHeading6" => {
289
+ underline: true
290
+ },
291
+ "PointBlank::DOM::InlineImage" => {
292
+ underline: true
293
+ },
294
+ "PointBlank::DOM::InlineLink" => {
295
+ underline: true
296
+ },
297
+ "PointBlank::DOM::InlinePre" => {},
298
+ "PointBlank::DOM::InlineEmphasis" => {
299
+ italics: true
300
+ },
301
+ "PointBlank::DOM::InlineStrong" => {
302
+ bold: true
303
+ },
304
+ "PointBlank::DOM::ULListElement" => {
305
+ bullet: true,
306
+ increase_level: true
307
+ },
308
+ "PointBlank::DOM::OLListElement" => {
309
+ numbered: true,
310
+ increase_level: true
311
+ },
312
+ "PointBlank::DOM::QuoteBlock" => {
313
+ leftline: true,
314
+ increase_level: true
315
+ },
316
+ "PointBlank::DOM::HorizontalRule" => {
317
+ underline_full_block: true
318
+ }
319
+ }.freeze
320
+
321
+ DEFAULT_EFFECT_PRIORITY = {
322
+ numbered: 10_000,
323
+ leftline: 9500,
324
+ bullet: 9000,
325
+ indent: 8500,
326
+ underline_full_block: 8000,
327
+ underline_block: 7500,
328
+ extra_newlines: 7000,
329
+ center: 6000,
330
+ rjust: 5500,
331
+ box: 5000,
332
+ underline: 4000,
333
+ italics: 3500,
334
+ bold: 3000,
335
+ fg: 2500,
336
+ bg: 2000,
337
+ strikethrough: 1500
338
+ }.freeze
339
+
340
+ # Class for managing styles and style overrides
341
+ class StyleManager
342
+ class << self
343
+ # Define a default style for specified class
344
+ # @param key [String] class name
345
+ # @param style [Hash] style
346
+ # @return [void]
347
+ def define_style(key, style)
348
+ @style ||= DEFAULT_STYLE.dup
349
+ @style[key] = style
350
+ end
351
+
352
+ # Define an effect priority value
353
+ # @param key [String] effect name
354
+ # @param priority [Integer] value of the priority
355
+ # @return [void]
356
+ def define_effect_priority(key, priority)
357
+ @effect_priority ||= DEFAULT_EFFECT_PRIORITY.dup
358
+ @effect_priority[key] = priority
359
+ end
360
+
361
+ # Get computed style
362
+ # @return [Hash]
363
+ def style
364
+ @style ||= DEFAULT_STYLE.dup
365
+ end
366
+
367
+ # Get computed effect priority
368
+ # @return [Hash]
369
+ def effect_priority
370
+ @effect_priority ||= DEFAULT_EFFECT_PRIORITY.dup
371
+ end
372
+ end
373
+
374
+ def initialize(overrides)
375
+ @style = self.class.style
376
+ @effect_priority = self.class.effect_priority
377
+ @style = @style.merge(overrides["style"]) if overrides["style"]
378
+ end
379
+
380
+ attr_reader :style, :effect_priority
381
+ end
382
+ end
383
+
384
+ # Primary document renderer
385
+ class Plainterm
386
+ include ::MMMD::TextManager
387
+
388
+ # @param input [String]
389
+ # @param options [Hash]
390
+ def initialize(input, options)
391
+ @doc = input
392
+ @color_mode = options.fetch("color", true)
393
+ @ansi_mode = options.fetch("ansi", true)
394
+ style_manager = PlaintermConstants::StyleManager.new(options)
395
+ @style = style_manager.style
396
+ @effect_priority = style_manager.effect_priority
397
+ @effects = @effect_priority.to_a.sort_by(&:last).map(&:first)
398
+ @options = options
399
+ @options["hsize"] ||= 80
400
+ end
401
+
402
+ # Return rendered text
403
+ # @return [String]
404
+ def render
405
+ _render(@doc, @options)
406
+ end
407
+
408
+ private
409
+
410
+ def _render(element, options, inline: false, level: 0, index: 0)
411
+ modeswitch = element.is_a?(::PointBlank::DOM::LeafBlock) ||
412
+ element.is_a?(::PointBlank::DOM::Paragraph)
413
+ inline ||= modeswitch
414
+ level += calculate_level_increase(element)
415
+ text = if element.children.empty?
416
+ element.content
417
+ else
418
+ element.children.map.with_index do |child, index|
419
+ _render(child, options, inline: inline,
420
+ level: level,
421
+ index: index)
422
+ end.join(inline ? '' : "\n\n")
423
+ end
424
+ run_filters(text, element, level: level,
425
+ modeswitch: modeswitch,
426
+ index: index)
427
+ end
428
+
429
+ def run_filters(text, element, level:, modeswitch:, index:)
430
+ element_style = @style[element.class.name]
431
+ return text unless element_style
432
+
433
+ hsize = @options["hsize"] - (4 * level)
434
+ text = wordwrap(text, hsize) if modeswitch
435
+ params = element_style.dup
436
+ params[:hsize] = hsize
437
+ params[:number] = index + 1
438
+ @effects.each do |effect|
439
+ text = method(effect).call(text, params) if element_style[effect]
440
+ end
441
+ text
442
+ end
443
+
444
+ def calculate_level_increase(element)
445
+ level = 0
446
+ element_style = @style[element.class.name]
447
+ level += 1 if element_style && element_style[:increase_level]
448
+ level
449
+ end
450
+ end
451
+ end
452
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.append(__dir__)
4
+
5
+ module MMMD
6
+ # Renderers from Markdown to expected output format
7
+ module Renderers
8
+ autoload :HTML, 'renderers/html'
9
+ autoload :Plainterm, 'renderers/plainterm'
10
+ end
11
+ end
data/lib/mmmd/util.rb ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module MMMD
6
+ # Utils for working with entities in strings
7
+ module EntityUtils
8
+ ENTITY_DATA = JSON.parse(File.read("#{__dir__}/entities.json"))
9
+
10
+ # Decode html entities in string
11
+ # @param string [String]
12
+ # @return [String]
13
+ def self.decode_entities(string)
14
+ string = string.gsub(/&#\d{1,7};/) do |match|
15
+ match[1..-2].to_i.chr("UTF-8")
16
+ end
17
+ string = string.gsub(/&#[xX][\dA-Fa-f]{1,6};/) do |match|
18
+ match[3..-2].to_i(16).chr("UTF-8")
19
+ end
20
+ string.gsub(/&\w+;/) do |match|
21
+ ENTITY_DATA[match] ? ENTITY_DATA[match]["characters"] : match
22
+ end
23
+ end
24
+
25
+ # Encode unsafe html entities in string (ASCII-compatible)
26
+ # @param string [String]
27
+ # @return [String]
28
+ # @sg-ignore
29
+ def self.encode_entities_ascii(string)
30
+ string.gsub("&", "&amp;")
31
+ .gsub("<", "&lt;")
32
+ .gsub(">", "&gt;")
33
+ .gsub('"', "&quot;")
34
+ .gsub("'", "&#39;")
35
+ .gsub(/[^\x00-\x7F]/) do |match|
36
+ "&#x#{match.codepoints[0]};"
37
+ end
38
+ end
39
+
40
+ # Encode unsafe html entities in string
41
+ # @param string [String]
42
+ # @return [String]
43
+ # @sg-ignore
44
+ def self.encode_entities(string)
45
+ string.gsub("&", "&amp;")
46
+ .gsub("<", "&lt;")
47
+ .gsub(">", "&gt;")
48
+ .gsub('"', "&quot;")
49
+ .gsub("'", "&#39;")
50
+ end
51
+
52
+ # Encode uri components that may break HTML syntax
53
+ # @param string [String]
54
+ # @return [String]
55
+ def self.encode_uri(string)
56
+ string.gsub('"', "%22")
57
+ .gsub("'", "%27")
58
+ .gsub(" ", "%20")
59
+ end
60
+ end
61
+ end
data/lib/mmmd.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mmmd/blankshell'
4
+ require_relative 'mmmd/renderers'
5
+
6
+ # Extensible, multi-format markdown processor
7
+ module MMMD
8
+ # Parse a Markdown document into a DOM form
9
+ # @param doc [String]
10
+ # @return [::PointBlank::DOM::Document]
11
+ def self.parse(doc)
12
+ ::PointBlank::DOM::Document.parse(doc)
13
+ end
14
+ end
data/security.md ADDED
@@ -0,0 +1,21 @@
1
+ Security acknowledgements
2
+ =========================
3
+
4
+ While special care has been taken to prevent some of the more common common
5
+ vulnerabilities that might arise from using this parser, it does not prevent
6
+ certain issues which **which should be acknowledged**.
7
+
8
+ - It is possible to inject a form of one-click XSS into the website. In
9
+ particular, there are no restrictions placed on urls embedded within the links
10
+ (as per the description of CommonMark specification). As such, something as
11
+ simple as `[test](<javascript:dangerous code here>)` would be more than enough
12
+ to employ such an exploit.
13
+ - While generally speaking the parser acts stable on most tests, and precents
14
+ stray HTML tokens from occuring in the output text where appropriate, due to
15
+ the nontrivial nature of the task some form of XSS injection may or may not
16
+ occur. If such an incident occurs, please report it to the current maintainer
17
+ of the project.
18
+ - User input should NOT be trusted when it comes to applying options to
19
+ rendering. Some renderers, such as the HTML renderer, allow modifying the
20
+ style parameter for rendered tags, which when passed control of to an
21
+ untrusted party may become an XSS attack vector.
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mmmd
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yessiest
8
+ autorequire:
9
+ bindir:
10
+ - bin
11
+ cert_chain: []
12
+ date: 2025-03-08 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description: |
15
+ MMMD (short for Mark My Manuscript Down) is a Markdown processor
16
+ (as in "parser and translator") with a CLI interface utility and
17
+ multiple modes of output (currently HTML and terminal).
18
+ email: yessiest@text.512mb.org
19
+ executables:
20
+ - mmmdpp
21
+ extensions: []
22
+ extra_rdoc_files:
23
+ - README.md
24
+ - architecture.md
25
+ - security.md
26
+ files:
27
+ - README.md
28
+ - architecture.md
29
+ - bin/mmmdpp
30
+ - lib/mmmd.rb
31
+ - lib/mmmd/blankshell.rb
32
+ - lib/mmmd/entities.json
33
+ - lib/mmmd/renderers.rb
34
+ - lib/mmmd/renderers/html.rb
35
+ - lib/mmmd/renderers/plainterm.rb
36
+ - lib/mmmd/util.rb
37
+ - security.md
38
+ homepage: https://adastra7.net/git/Yessiest/rubymark
39
+ licenses:
40
+ - AGPL-3.0-or-later
41
+ metadata: {}
42
+ post_install_message:
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: 3.0.0
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubygems_version: 3.5.22
58
+ signing_key:
59
+ specification_version: 4
60
+ summary: Modular, compliant Markdown processor
61
+ test_files: []