beid 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +52 -0
- data/docs/adr/001-byte-range-edits.md +16 -0
- data/docs/adr/002-html-comment-directives.md +16 -0
- data/lib/beid/directive.rb +29 -0
- data/lib/beid/document.rb +112 -0
- data/lib/beid/editing.rb +173 -0
- data/lib/beid/inline_parser.rb +704 -0
- data/lib/beid/node.rb +27 -0
- data/lib/beid/parser.rb +851 -0
- data/lib/beid/version.rb +5 -0
- data/lib/beid.rb +13 -0
- data/sig/beid.rbs +51 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 203b09a7d1880b4113345b87d2571e0c84aefb40371e6a4b9f97fdf32b417c7e
|
|
4
|
+
data.tar.gz: 72145389c81cb8e86045bdef7bc3373f5a371ea57717676b3990a580cb9a7e7a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 68d48286462af7a869d4007540357c2a42957df7c2dee6a98e9d827309de9d3c426ba26d29de9262210b868f0a08ac52b39cfb3a98eb01568e0494a96c2d5f8d
|
|
7
|
+
data.tar.gz: d45e9fd574d845ddc0d69567f044addd222882d9bc2644ca18849c8ee93ff45ec14d7f0455acfbe49c8994eea1816edd227f0c516b4bfe7b378b2da2668284e1
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Beid
|
|
2
|
+
|
|
3
|
+
Beid (ο¹ Eridani; Arabic *bayḍ*, “egg”) parses Markdown into a source-positioned tree and edits the original bytes in place. It is intended for applications that need to update part of a Markdown document without normalizing untouched formatting.
|
|
4
|
+
|
|
5
|
+
Beid uses Ruby's standard library at runtime and supports Ruby 3.1 and later.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "beid"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "beid"
|
|
17
|
+
|
|
18
|
+
source = "# Title\n\nKeep this *formatting*.\n"
|
|
19
|
+
document = Beid::Document.parse(source)
|
|
20
|
+
paragraph = document.root.children.find { |node| node.type == :paragraph }
|
|
21
|
+
|
|
22
|
+
updated = Beid::Editing.replace_text(document, paragraph, "A new sentence.")
|
|
23
|
+
puts updated
|
|
24
|
+
# # Title
|
|
25
|
+
#
|
|
26
|
+
# A new sentence.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`Document#source` and `#to_s` return the exact source string. Node ranges and all offsets are half-open byte ranges in that original UTF-8 string. `position_at` returns zero-based line and Unicode-codepoint column; `utf16_position_at` returns a zero-based LSP position. `nodes_at` returns the document-to-deepest-node path at a byte offset.
|
|
30
|
+
|
|
31
|
+
`Beid::Editing` provides `replace`, `replace_text`, `insert_before`, `insert_after`, `remove`, `set_attribute`, `set_directive`, and `move`. Each operation returns a new parsed `Document`; the original remains unchanged. Edits splice only the requested byte range. Replacements are reparsed, and nodes from another document are rejected.
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
heading = document.root.children.first
|
|
35
|
+
updated = Beid::Editing.set_attribute(document, heading, :level, 2)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`Document.parse(text, gfm: true, front_matter: true)` retains YAML front matter as raw text, recognizes HTML-comment directives (`<!-- layout: two-column -->`), fenced code blocks, headings, paragraphs, nested lists, block quotes, tables, task-list items, strikethrough, and footnote references/definitions. `::: name` fenced divs are retained as directive nodes. Inline nodes include emphasis, strong emphasis, inline and reference links, autolinks, images, code spans, and text. Reference definitions are retained as `:link_definition` nodes and resolved link nodes carry their destination and source ranges.
|
|
39
|
+
|
|
40
|
+
Beid does not evaluate YAML or render HTML. It is not a complete CommonMark/GFM implementation: unsupported or ambiguous syntax may be represented as plain text or raw HTML. The official CommonMark 0.31.2 fixture is bundled under `spec/fixtures/commonmark`. CI verifies exact source round-tripping and node byte ranges, and compares a test-only HTML rendering of Beid's tree with the fixture's expected output after HTML5 DOM normalization. The current semantic HTML DOM agreement is 620/652 (95.1%), meeting the design's 95% CommonMark semantic-conformance gate; the remaining mismatches are listed by section in CI output. Nokogiri is a development-only dependency for this oracle and is not a runtime gem dependency. Regardless of parser coverage, `Document#to_s` is an exact round trip because serialization returns the original source rather than regenerating Markdown from the tree. Treat the tree as a best-effort editing view and keep application-level edits within recognized node ranges.
|
|
41
|
+
|
|
42
|
+
## Development
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
bundle install
|
|
46
|
+
bundle exec rake
|
|
47
|
+
bundle exec rbs -I sig validate
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# ADR 001: Byte-range edits instead of AST reserialization
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-09-23
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Beid is meant to support editing Markdown documents without changing untouched formatting. Rebuilding source from a parsed tree would normalize markers, spacing, line endings, and syntax Beid does not understand.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
Each node keeps a half-open byte range into an immutable source snapshot. Editing operations splice only that range and reparse the result; `Document#to_s` returns the stored source verbatim.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
Unedited bytes remain unchanged, including syntax outside Beid's supported subset. Consumers must use a node from the same document snapshot and reparse after edits; ranges from an earlier document are not reusable.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# ADR 002: HTML comments as directives
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-09-23
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Applications built on Markdown need lightweight metadata without introducing a custom block syntax that ordinary Markdown tools cannot preserve.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
Beid recognizes a standalone HTML comment containing one `key: value` pair, such as `<!-- layout: two-column -->`, as a directive. It also retains `::: name` fenced div blocks as source-positioned directive nodes, without interpreting their contents.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
Directive metadata remains valid Markdown and round-trips unchanged. YAML and directive values are not evaluated; applications define their meaning. Beid's directive helper deliberately handles one key-value pair per HTML comment.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beid
|
|
4
|
+
module Directive
|
|
5
|
+
PATTERN = /\A<!--[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*:[ \t]*(.*?)[ \t]*-->\z/m
|
|
6
|
+
|
|
7
|
+
def self.parse(html_comment)
|
|
8
|
+
match = PATTERN.match(html_comment)
|
|
9
|
+
return nil unless match
|
|
10
|
+
|
|
11
|
+
{ match[1] => match[2] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.render(values)
|
|
15
|
+
raise TypeError, "directive must be a Hash" unless values.is_a?(Hash)
|
|
16
|
+
raise ArgumentError, "one key-value pair per comment is supported" unless values.length == 1
|
|
17
|
+
|
|
18
|
+
fields = values.map do |key, value|
|
|
19
|
+
key = key.to_s
|
|
20
|
+
value = value.to_s
|
|
21
|
+
raise ArgumentError, "invalid directive key" unless key.match?(/\A[A-Za-z][A-Za-z0-9_-]*\z/)
|
|
22
|
+
raise ArgumentError, "directive value cannot contain a comment terminator or newline" if value.include?("-->") || value.match?(/[\r\n]/)
|
|
23
|
+
|
|
24
|
+
"#{key}: #{value}"
|
|
25
|
+
end
|
|
26
|
+
"<!-- #{fields.join("; ")} -->"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beid
|
|
4
|
+
class Document
|
|
5
|
+
attr_reader :source, :root, :front_matter, :options, :diagnostics
|
|
6
|
+
|
|
7
|
+
def self.parse(text, gfm: true, front_matter: true)
|
|
8
|
+
raise TypeError, "source must be a String" unless text.is_a?(String)
|
|
9
|
+
|
|
10
|
+
source = text.dup.freeze
|
|
11
|
+
options = { gfm: gfm, front_matter: front_matter }.freeze
|
|
12
|
+
root, raw_front_matter, diagnostics = Parser.new(source, **options).parse
|
|
13
|
+
new(source, root, raw_front_matter, options, diagnostics)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(source, root, front_matter, options, diagnostics)
|
|
17
|
+
@source, @root, @front_matter = source, root, front_matter&.freeze
|
|
18
|
+
@options, @diagnostics = options, diagnostics.freeze
|
|
19
|
+
@nodes = {}
|
|
20
|
+
walk(@root) { |node| @nodes[node.object_id] = node }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def nodes_at(offset)
|
|
24
|
+
check_offset(offset)
|
|
25
|
+
return [@root] if @source.empty? && offset.zero?
|
|
26
|
+
return [] unless @root.range.cover?(offset)
|
|
27
|
+
|
|
28
|
+
result = [@root]
|
|
29
|
+
current = @root
|
|
30
|
+
loop do
|
|
31
|
+
child = current.children.find { |entry| entry.range.cover?(offset) }
|
|
32
|
+
break unless child
|
|
33
|
+
|
|
34
|
+
result << child
|
|
35
|
+
current = child
|
|
36
|
+
end
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def node_at(offset)
|
|
41
|
+
nodes_at(offset).last
|
|
42
|
+
rescue RangeError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def range_of(node)
|
|
47
|
+
raise TypeError, "node must be a Beid::Node" unless node.is_a?(Node)
|
|
48
|
+
raise ArgumentError, "node does not belong to this document" unless @nodes[node.object_id].equal?(node)
|
|
49
|
+
|
|
50
|
+
node.range
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns zero-based [line, Unicode-codepoint column] for a UTF-8 byte offset.
|
|
54
|
+
def position_at(offset)
|
|
55
|
+
check_offset(offset)
|
|
56
|
+
raise EncodingError, "positions require valid UTF-8" unless @source.valid_encoding?
|
|
57
|
+
|
|
58
|
+
line_index = line_index_at(offset)
|
|
59
|
+
start = line_starts[line_index]
|
|
60
|
+
prefix = @source.byteslice(start...offset).to_s.sub(/(?:\r\n|\r|\n)\z/, "")
|
|
61
|
+
[line_index, prefix.length]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns zero-based LSP [line, UTF-16 code-unit column].
|
|
65
|
+
def utf16_position_at(offset)
|
|
66
|
+
line, = position_at(offset)
|
|
67
|
+
start = line_starts[line]
|
|
68
|
+
prefix = @source.byteslice(start...offset).to_s.sub(/(?:\r\n|\r|\n)\z/, "")
|
|
69
|
+
[line, prefix.encode("UTF-16LE").bytesize / 2]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def valid?
|
|
73
|
+
@source.valid_encoding? && @diagnostics.empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def to_s
|
|
77
|
+
@source
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def include_node?(node)
|
|
81
|
+
node.is_a?(Node) && @nodes[node.object_id].equal?(node)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def walk(node, &block)
|
|
87
|
+
yield node
|
|
88
|
+
node.children.each { |child| walk(child, &block) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def check_offset(offset)
|
|
92
|
+
unless offset.is_a?(Integer) && (0..@source.bytesize).cover?(offset)
|
|
93
|
+
raise RangeError, "Byte offset is outside the document"
|
|
94
|
+
end
|
|
95
|
+
if @source.valid_encoding? && offset < @source.bytesize && (@source.getbyte(offset) & 0xc0) == 0x80
|
|
96
|
+
raise RangeError, "Byte offset splits a UTF-8 character"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def line_index_at(offset)
|
|
101
|
+
(line_starts.bsearch_index { |start| start > offset } || line_starts.length) - 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def line_starts
|
|
105
|
+
@line_starts ||= begin
|
|
106
|
+
starts = [0]
|
|
107
|
+
@source.b.to_enum(:scan, /\r\n|\r|\n/n).each { starts << Regexp.last_match.end(0) }
|
|
108
|
+
starts
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/beid/editing.rb
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beid
|
|
4
|
+
module Editing
|
|
5
|
+
def self.replace(document, node, markdown)
|
|
6
|
+
document.range_of(node)
|
|
7
|
+
raise TypeError, "markdown must be a String" unless markdown.is_a?(String)
|
|
8
|
+
terminator = document.source.byteslice(node.range.end - 2...node.range.end).to_s
|
|
9
|
+
terminator = document.source.byteslice(node.range.end - 1...node.range.end).to_s unless ["\r\n", "\n", "\r"].include?(terminator)
|
|
10
|
+
markdown += terminator if !markdown.empty? && ["\r\n", "\n", "\r"].include?(terminator) && !markdown.end_with?("\r", "\n")
|
|
11
|
+
edit_range(document, node, node.range, markdown)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.replace_text(document, node, text)
|
|
15
|
+
raise TypeError, "text must be a String" unless text.is_a?(String)
|
|
16
|
+
if node.type == :code_span
|
|
17
|
+
marker_size = [node.marker.length, text.scan(/`+/).map(&:length).max.to_i + 1].max
|
|
18
|
+
marker = "`" * marker_size
|
|
19
|
+
return edit(document, node, "#{marker}#{text}#{marker}")
|
|
20
|
+
end
|
|
21
|
+
range = case node.type
|
|
22
|
+
when :text
|
|
23
|
+
document.range_of(node)
|
|
24
|
+
when :heading, :paragraph, :footnote_definition
|
|
25
|
+
node.attributes.fetch(:content_range)
|
|
26
|
+
when :link, :image
|
|
27
|
+
node.attributes.fetch(:label_range)
|
|
28
|
+
when :emphasis, :strong, :strikethrough
|
|
29
|
+
node.range.begin + node.marker.bytesize...(node.range.end - node.marker.bytesize)
|
|
30
|
+
else
|
|
31
|
+
raise ArgumentError, "#{node.type} does not have replaceable text"
|
|
32
|
+
end
|
|
33
|
+
edit_range(document, node, range, escape_text(text))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.insert_after(document, node, markdown)
|
|
37
|
+
insert(document, node, markdown, after: true)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.insert_before(document, node, markdown)
|
|
41
|
+
insert(document, node, markdown, after: false)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.remove(document, node)
|
|
45
|
+
edit(document, node, "")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.set_attribute(document, node, key, value)
|
|
49
|
+
document.range_of(node)
|
|
50
|
+
case [node.type, key.to_sym]
|
|
51
|
+
when [:heading, :level]
|
|
52
|
+
level = Integer(value)
|
|
53
|
+
raise ArgumentError, "heading level must be between 1 and 6" unless (1..6).cover?(level)
|
|
54
|
+
|
|
55
|
+
if node.attributes[:style] == :atx
|
|
56
|
+
range = node.attributes.fetch(:marker_range)
|
|
57
|
+
edit_range(document, node, range, "#" * level)
|
|
58
|
+
else
|
|
59
|
+
edit_range(document, node, node.attributes.fetch(:marker_range), (level == 1 ? "=" : "-") * [node.marker.length, 3].max)
|
|
60
|
+
end
|
|
61
|
+
when [:link, :destination], [:image, :destination]
|
|
62
|
+
edit_range(document, node, node.attributes.fetch(:destination_range), escape_destination(value.to_s))
|
|
63
|
+
else
|
|
64
|
+
raise ArgumentError, "unsupported attribute #{key.inspect} for #{node.type}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.set_directive(document, node, key, value)
|
|
69
|
+
document.range_of(node)
|
|
70
|
+
raise ArgumentError, "node is not an HTML directive" unless node.type == :directive && node.attributes[:kind] == :html_comment
|
|
71
|
+
|
|
72
|
+
values = Directive.parse(document.source.byteslice(node.range)) || {}
|
|
73
|
+
values[key.to_s] = value.to_s
|
|
74
|
+
edit_range(document, node, node.attributes.fetch(:comment_range), Directive.render(values))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.move(document, node, before: nil, after: nil)
|
|
78
|
+
document.range_of(node)
|
|
79
|
+
raise ArgumentError, "specify exactly one of before or after" unless (!!before ^ !!after)
|
|
80
|
+
target = before || after
|
|
81
|
+
target_range = document.range_of(target)
|
|
82
|
+
source_range = node.range
|
|
83
|
+
raise ArgumentError, "cannot move a node relative to itself" if node.equal?(target)
|
|
84
|
+
raise ArgumentError, "move ranges overlap" if source_range.begin < target_range.end && target_range.begin < source_range.end
|
|
85
|
+
|
|
86
|
+
source = document.source.b
|
|
87
|
+
moved = source.byteslice(source_range)
|
|
88
|
+
remainder = source.byteslice(0...source_range.begin) + source.byteslice(source_range.end..-1).to_s
|
|
89
|
+
destination = before ? target_range.begin : target_range.end
|
|
90
|
+
destination -= source_range.size if source_range.end <= destination
|
|
91
|
+
result = remainder.byteslice(0...destination) + moved + remainder.byteslice(destination..-1).to_s
|
|
92
|
+
parse_result(document, result)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.insert(document, node, markdown, after:)
|
|
96
|
+
document.range_of(node)
|
|
97
|
+
raise TypeError, "markdown must be a String" unless markdown.is_a?(String)
|
|
98
|
+
markdown = adapt_list_marker(markdown, node.marker) if node.type == :list_item
|
|
99
|
+
source = document.source.b
|
|
100
|
+
range = node.range
|
|
101
|
+
newline = newline_for(document.source)
|
|
102
|
+
if after
|
|
103
|
+
offset = range.end
|
|
104
|
+
if node.type == :list_item
|
|
105
|
+
item = source.byteslice(range)
|
|
106
|
+
prefix = item.end_with?("\r", "\n") ? "" : newline
|
|
107
|
+
insertion = prefix + markdown + (offset < source.bytesize ? newline : "")
|
|
108
|
+
else
|
|
109
|
+
insertion = newline + markdown
|
|
110
|
+
insertion += newline if offset < source.bytesize
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
offset = range.begin
|
|
114
|
+
insertion = if node.type == :list_item
|
|
115
|
+
markdown + newline
|
|
116
|
+
else
|
|
117
|
+
markdown + newline * 2
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
result = source.byteslice(0...offset) + insertion.b + source.byteslice(offset..-1).to_s
|
|
121
|
+
parse_result(document, result)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.edit(document, node, markdown)
|
|
125
|
+
document.range_of(node)
|
|
126
|
+
raise TypeError, "markdown must be a String" unless markdown.is_a?(String)
|
|
127
|
+
edit_range(document, node, node.range, markdown)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.edit_range(document, node, range, replacement)
|
|
131
|
+
document.range_of(node)
|
|
132
|
+
unless range.is_a?(Range) && range.begin >= node.range.begin && range.end <= node.range.end
|
|
133
|
+
raise ArgumentError, "edit range must be within the node"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
source = document.source.b
|
|
137
|
+
result = source.byteslice(0...range.begin) + replacement.b + source.byteslice(range.end..-1).to_s
|
|
138
|
+
parse_result(document, result)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.parse_result(document, source)
|
|
142
|
+
result = Document.parse(source.force_encoding(document.source.encoding), **document.options)
|
|
143
|
+
raise Error, "edit produced invalid Markdown" unless result.valid?
|
|
144
|
+
|
|
145
|
+
result
|
|
146
|
+
end
|
|
147
|
+
private_class_method :parse_result
|
|
148
|
+
|
|
149
|
+
def self.escape_text(text)
|
|
150
|
+
text.gsub(/([\\`*_{}\[\]<>!])/) { |character| "\\#{character}" }
|
|
151
|
+
end
|
|
152
|
+
private_class_method :escape_text
|
|
153
|
+
|
|
154
|
+
def self.escape_destination(value)
|
|
155
|
+
raise ArgumentError, "destination cannot contain newlines or angle brackets" if value.match?(/[\r\n<>]/)
|
|
156
|
+
|
|
157
|
+
value.gsub(/([\\() ])/) { |character| "\\#{character}" }
|
|
158
|
+
end
|
|
159
|
+
private_class_method :escape_destination
|
|
160
|
+
|
|
161
|
+
def self.adapt_list_marker(markdown, marker)
|
|
162
|
+
return markdown unless marker && (match = /\A( {0,3})(?:[-+*]|\d{1,9}[.)])(?=[ \t])/.match(markdown))
|
|
163
|
+
|
|
164
|
+
markdown.sub(/\A( {0,3})(?:[-+*]|\d{1,9}[.)])(?=[ \t])/, "\\1#{marker}")
|
|
165
|
+
end
|
|
166
|
+
private_class_method :adapt_list_marker
|
|
167
|
+
|
|
168
|
+
def self.newline_for(source)
|
|
169
|
+
source[/\r\n|\n|\r/] || "\n"
|
|
170
|
+
end
|
|
171
|
+
private_class_method :newline_for
|
|
172
|
+
end
|
|
173
|
+
end
|