markbridge 0.1.3 → 0.2.1

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/lib/markbridge/ast/details.rb +24 -0
  4. data/lib/markbridge/ast/element.rb +63 -0
  5. data/lib/markbridge/ast.rb +1 -0
  6. data/lib/markbridge/conversion.rb +40 -0
  7. data/lib/markbridge/parse.rb +20 -0
  8. data/lib/markbridge/parsers/bbcode/handler_registry.rb +25 -2
  9. data/lib/markbridge/parsers/bbcode/handlers/raw_handler.rb +13 -2
  10. data/lib/markbridge/parsers/html/handler_registry.rb +97 -17
  11. data/lib/markbridge/parsers/html/handlers/self_closing_handler.rb +26 -0
  12. data/lib/markbridge/parsers/html/handlers/span_handler.rb +74 -0
  13. data/lib/markbridge/parsers/html/parser.rb +88 -18
  14. data/lib/markbridge/parsers/html.rb +2 -0
  15. data/lib/markbridge/parsers/media_wiki/inline_parser.rb +21 -8
  16. data/lib/markbridge/parsers/media_wiki/parser.rb +13 -5
  17. data/lib/markbridge/parsers/text_formatter/handler_registry.rb +27 -4
  18. data/lib/markbridge/parsers/text_formatter/handlers/attachment_handler.rb +1 -1
  19. data/lib/markbridge/parsers/text_formatter/handlers/attribute_handler.rb +1 -1
  20. data/lib/markbridge/parsers/text_formatter/handlers/base_handler.rb +1 -1
  21. data/lib/markbridge/parsers/text_formatter/handlers/code_handler.rb +1 -1
  22. data/lib/markbridge/parsers/text_formatter/handlers/email_handler.rb +1 -1
  23. data/lib/markbridge/parsers/text_formatter/handlers/image_handler.rb +1 -1
  24. data/lib/markbridge/parsers/text_formatter/handlers/list_handler.rb +1 -1
  25. data/lib/markbridge/parsers/text_formatter/handlers/quote_handler.rb +1 -1
  26. data/lib/markbridge/parsers/text_formatter/handlers/simple_handler.rb +1 -1
  27. data/lib/markbridge/parsers/text_formatter/handlers/table_cell_handler.rb +1 -1
  28. data/lib/markbridge/parsers/text_formatter/handlers/url_handler.rb +1 -1
  29. data/lib/markbridge/parsers/text_formatter/parser.rb +17 -3
  30. data/lib/markbridge/renderers/discourse/identity_escaper.rb +37 -0
  31. data/lib/markbridge/renderers/discourse/markdown_escaper.rb +83 -8
  32. data/lib/markbridge/renderers/discourse/postprocessor.rb +53 -0
  33. data/lib/markbridge/renderers/discourse/render_context.rb +14 -40
  34. data/lib/markbridge/renderers/discourse/renderer.rb +15 -5
  35. data/lib/markbridge/renderers/discourse/rendering_interface.rb +4 -3
  36. data/lib/markbridge/renderers/discourse/tag_library.rb +42 -2
  37. data/lib/markbridge/renderers/discourse/tags/align_tag.rb +2 -2
  38. data/lib/markbridge/renderers/discourse/tags/code_tag.rb +5 -3
  39. data/lib/markbridge/renderers/discourse/tags/details_tag.rb +46 -0
  40. data/lib/markbridge/renderers/discourse/tags/heading_tag.rb +1 -1
  41. data/lib/markbridge/renderers/discourse/tags/paragraph_tag.rb +5 -2
  42. data/lib/markbridge/renderers/discourse/tags/quote_tag.rb +4 -3
  43. data/lib/markbridge/renderers/discourse/tags/underline_tag.rb +13 -0
  44. data/lib/markbridge/renderers/discourse.rb +3 -0
  45. data/lib/markbridge/version.rb +1 -1
  46. data/lib/markbridge.rb +274 -111
  47. metadata +9 -11
  48. data/lib/markbridge/configuration.rb +0 -11
  49. data/lib/markbridge/processors/discourse_markdown/code_block_tracker.rb +0 -218
  50. data/lib/markbridge/processors/discourse_markdown/detectors/base.rb +0 -63
  51. data/lib/markbridge/processors/discourse_markdown/detectors/event.rb +0 -64
  52. data/lib/markbridge/processors/discourse_markdown/detectors/mention.rb +0 -57
  53. data/lib/markbridge/processors/discourse_markdown/detectors/poll.rb +0 -52
  54. data/lib/markbridge/processors/discourse_markdown/detectors/upload.rb +0 -98
  55. data/lib/markbridge/processors/discourse_markdown/scanner.rb +0 -194
  56. data/lib/markbridge/processors/discourse_markdown.rb +0 -16
  57. data/lib/markbridge/processors.rb +0 -8
@@ -1,218 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- # Tracks whether the current position is inside a code block.
7
- # Handles fenced code blocks (``` or ~~~), indented code blocks (4+ spaces),
8
- # and inline code (`).
9
- #
10
- # Fenced code blocks:
11
- # - Can have leading whitespace (up to 3 spaces)
12
- # - Opening fence: 3+ backticks or tildes, optionally followed by language
13
- # - Closing fence: same or more fence characters as opening
14
- #
15
- # Indented code blocks:
16
- # - Lines indented by 4+ spaces or 1+ tab
17
- # - Continues until a non-blank line with less indentation
18
- #
19
- # Inline code:
20
- # - Single or multiple backticks as delimiter
21
- # - Content between matching backticks
22
- class CodeBlockTracker
23
- # @return [Boolean] true if currently inside a fenced code block
24
- attr_reader :in_fenced_block
25
-
26
- # @return [Boolean] true if currently inside an indented code block
27
- attr_reader :in_indented_block
28
-
29
- # @return [Boolean] true if currently inside an inline code span
30
- attr_reader :in_inline_code
31
-
32
- def initialize
33
- @in_fenced_block = false
34
- @in_indented_block = false
35
- @in_inline_code = false
36
- # @fence_char / @fence_length / @inline_delimiter are set by
37
- # open_fence / open_inline before any helper reads them;
38
- # they're only consulted when the corresponding in_X flag is
39
- # true, which requires a prior open_* call.
40
- end
41
-
42
- # Check if currently inside any code context
43
- # @return [Boolean]
44
- def in_code?
45
- @in_fenced_block || @in_indented_block || @in_inline_code
46
- end
47
-
48
- # Check if position is at start of a fenced code block boundary
49
- # @param input [String] the full input string
50
- # @param pos [Integer] current position
51
- # @param line_start [Boolean] true if pos is at the start of a line
52
- # @return [Integer, nil] end position after fence, or nil if no fence
53
- def check_fenced_boundary(input, pos, line_start:)
54
- return nil unless line_start
55
-
56
- input_length = input.length
57
- scan_pos = skip_leading_spaces(input, pos)
58
- # No `scan_pos >= input_length` guard: `input[input_length]` is
59
- # nil, and `nil == "`"` / `nil == "~"` are both false so the
60
- # next check returns nil anyway.
61
- fence_char = input[scan_pos]
62
- return nil unless fence_char == "`" || fence_char == "~"
63
-
64
- fence_length, scan_pos = count_fence_chars(input, scan_pos, fence_char, input_length)
65
- return nil if fence_length < 3
66
-
67
- if @in_fenced_block
68
- try_close_fence(input, scan_pos, fence_char, fence_length, input_length)
69
- else
70
- open_fence(input, scan_pos, fence_char, fence_length, input_length)
71
- end
72
- end
73
-
74
- # Check if line at position is an indented code block line.
75
- # A line is considered indented code if it starts with 4+ spaces or 1+ tab.
76
- # Blank lines within an indented block are considered part of it.
77
- #
78
- # @param input [String] the full input string
79
- # @param pos [Integer] current position (must be at line start)
80
- # @param line_start [Boolean] true if pos is at the start of a line
81
- # @return [Integer, nil] end position after the line, or nil if not indented code
82
- def check_indented_boundary(input, pos, line_start:)
83
- return nil unless line_start
84
- return nil if @in_fenced_block # Fenced blocks take precedence
85
-
86
- input_length = input.length
87
- line_end = input.index("\n", pos) || input_length
88
- line_content = input[pos...line_end]
89
- is_blank = line_content.match?(/\A\s*\z/)
90
- has_code_indent = line_content.start_with?(" ") || line_content.start_with?("\t")
91
-
92
- if @in_indented_block
93
- if is_blank || has_code_indent
94
- pos_after_line(line_end, input_length)
95
- else
96
- @in_indented_block = false
97
- nil
98
- end
99
- elsif has_code_indent
100
- @in_indented_block = true
101
- pos_after_line(line_end, input_length)
102
- end
103
- end
104
-
105
- # Check for inline code boundary
106
- # @param input [String] the full input string
107
- # @param pos [Integer] current position
108
- # @return [Integer, nil] end position after inline code, or nil if not at boundary
109
- def check_inline_boundary(input, pos)
110
- return nil if @in_fenced_block || @in_indented_block
111
- # No `pos >= input_length` guard: `input[input_length]` is nil,
112
- # and `nil != "`"` is true so the next check returns nil anyway.
113
- return nil if input[pos] != "`"
114
-
115
- input_length = input.length
116
- if @in_inline_code
117
- try_close_inline(input, pos, input_length)
118
- else
119
- open_inline(input, pos, input_length)
120
- end
121
- end
122
-
123
- private
124
-
125
- # Skip up to 3 leading spaces of indentation.
126
- def skip_leading_spaces(input, pos)
127
- scan_pos = pos
128
- spaces = 0
129
- while spaces < 3 && input[scan_pos] == " "
130
- spaces += 1
131
- scan_pos += 1
132
- end
133
- scan_pos
134
- end
135
-
136
- # Count consecutive fence characters and return [count, new_position].
137
- def count_fence_chars(input, scan_pos, fence_char, input_length)
138
- fence_length = 0
139
- while scan_pos < input_length && input[scan_pos] == fence_char
140
- fence_length += 1
141
- scan_pos += 1
142
- end
143
- [fence_length, scan_pos]
144
- end
145
-
146
- # Try to close an open fenced code block. Returns position after fence or nil.
147
- def try_close_fence(input, scan_pos, fence_char, fence_length, input_length)
148
- return nil unless fence_char == @fence_char && fence_length >= @fence_length
149
-
150
- # Closing fence must be followed only by spaces then newline/EOF
151
- scan_pos += 1 while scan_pos < input_length && input[scan_pos] == " "
152
- return nil unless scan_pos >= input_length || input[scan_pos] == "\n"
153
-
154
- # @fence_char / @fence_length are not reset here: they are only
155
- # consulted while @in_fenced_block is true, and open_fence
156
- # overwrites them on the next opening.
157
- @in_fenced_block = false
158
- pos_after_line(scan_pos, input_length)
159
- end
160
-
161
- # Open a new fenced code block. Returns position after the opening line.
162
- def open_fence(input, scan_pos, fence_char, fence_length, input_length)
163
- # Skip to end of line (info string)
164
- scan_pos += 1 while scan_pos < input_length && input[scan_pos] != "\n"
165
-
166
- @in_fenced_block = true
167
- @fence_char = fence_char
168
- @fence_length = fence_length
169
- pos_after_line(scan_pos, input_length)
170
- end
171
-
172
- # Try to close inline code. Returns position after delimiter or nil.
173
- def try_close_inline(input, pos, input_length)
174
- delimiter_length = @inline_delimiter.length
175
- return nil unless input[pos, delimiter_length] == @inline_delimiter
176
-
177
- # Should not be followed by another backtick
178
- next_pos = pos + delimiter_length
179
- return nil if next_pos < input_length && input[next_pos] == "`"
180
-
181
- # @inline_delimiter is not reset here: it is only consulted
182
- # while @in_inline_code is true, and open_inline overwrites it
183
- # on the next opening.
184
- @in_inline_code = false
185
- next_pos
186
- end
187
-
188
- # Open inline code. Returns position after opening delimiter.
189
- def open_inline(input, pos, input_length)
190
- delimiter_start = pos
191
- pos += 1 while pos < input_length && input[pos] == "`"
192
-
193
- @inline_delimiter = input[delimiter_start...pos]
194
- @in_inline_code = true
195
- pos
196
- end
197
-
198
- # Return position after a line (after newline if present, otherwise at end).
199
- def pos_after_line(line_end, input_length)
200
- line_end < input_length ? line_end + 1 : line_end
201
- end
202
-
203
- public
204
-
205
- # Reset the tracker state. The @fence_char / @fence_length /
206
- # @inline_delimiter companions are not cleared: they're only
207
- # consulted while the corresponding in_* flag is true, and
208
- # open_fence / open_inline overwrites them on the next
209
- # opening (same pattern as try_close_fence / try_close_inline).
210
- def reset!
211
- @in_fenced_block = false
212
- @in_indented_block = false
213
- @in_inline_code = false
214
- end
215
- end
216
- end
217
- end
218
- end
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- module Detectors
7
- # Result of a successful detection
8
- # @attr_reader start_pos [Integer] start position in input
9
- # @attr_reader end_pos [Integer] end position in input (exclusive)
10
- # @attr_reader node [AST::Node] the AST node representing the detected construct
11
- Match = Data.define(:start_pos, :end_pos, :node)
12
-
13
- # Base class for construct detectors.
14
- # Subclasses implement detection logic for specific constructs
15
- # (mentions, polls, events, uploads).
16
- #
17
- # @abstract Subclass and implement {#detect}
18
- class Base
19
- # Attempt to detect a construct at the given position.
20
- #
21
- # @param input [String] the full input string
22
- # @param pos [Integer] current position to check
23
- # @return [Match, nil] match result or nil if no match
24
- def detect(input, pos)
25
- raise NotImplementedError, "#{self.class} must implement #detect"
26
- end
27
-
28
- private
29
-
30
- # Helper to check if position is at a word boundary (for mentions, etc.)
31
- # @param input [String] the input string
32
- # @param pos [Integer] position to check
33
- # @return [Boolean] true if at word boundary
34
- def word_boundary?(input, pos)
35
- return true if pos == 0
36
-
37
- prev_char = input[pos - 1]
38
- !prev_char.match?(/\w/)
39
- end
40
-
41
- WORD_PATTERN = /\A[\w\-]*/
42
- private_constant :WORD_PATTERN
43
-
44
- # Helper to extract a word starting at position. Caller must ensure
45
- # pos is within input bounds (`pos <= input.length`).
46
- # @param input [String] the input string
47
- # @param pos [Integer] starting position
48
- # @return [String] the word (may be empty)
49
- def extract_word(input, pos)
50
- input[pos..].match(WORD_PATTERN)[0]
51
- end
52
-
53
- # Parse key="value" or key='value' attribute pairs from a string.
54
- # @param attr_string [String] the attribute string to parse
55
- # @return [Hash<String, String>] parsed attributes with downcased keys
56
- def parse_attributes(attr_string)
57
- attr_string.scan(/(\w+)=["']([^"']*)["']/).to_h.transform_keys(&:downcase)
58
- end
59
- end
60
- end
61
- end
62
- end
63
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- module Detectors
7
- # Detects Discourse event blocks [event]...[/event].
8
- #
9
- # @example
10
- # detector = Event.new
11
- # input = '[event name="Meeting" start="2025-12-15 14:00"][/event]'
12
- # match = detector.detect(input, 0)
13
- # match.node.name # => "Meeting"
14
- class Event < Base
15
- OPEN_TAG_PATTERN = /\[event(?<attrs>[^\]]*)\]/i
16
- CLOSE_TAG_PATTERN = %r{\[/event\]}i
17
-
18
- # Attempt to detect an event at the given position.
19
- #
20
- # @param input [String] the full input string
21
- # @param pos [Integer] current position to check
22
- # @return [Match, nil] match result or nil if no match
23
- def detect(input, pos)
24
- remaining = input[pos..]
25
- open_match = OPEN_TAG_PATTERN.match(remaining)
26
- return nil unless open_match&.begin(0)&.zero?
27
-
28
- # Find closing tag. The opening tag pattern forbids `]` between
29
- # `[event` and its closing `]`, so `[/event]` cannot appear inside
30
- # the opening tag - no need to skip past it.
31
- close_match = CLOSE_TAG_PATTERN.match(remaining)
32
- return nil unless close_match
33
-
34
- # Extract raw content
35
- end_pos = pos + close_match.end(0)
36
- raw = input[pos...end_pos]
37
-
38
- # Parse attributes from opening tag
39
- attrs = parse_attributes(open_match[:attrs])
40
-
41
- # Validate required attributes
42
- name = attrs["name"]
43
- starts_at = attrs["start"]
44
- return nil if name.nil? || starts_at.nil?
45
-
46
- node =
47
- AST::Event.new(
48
- name:,
49
- starts_at:,
50
- ends_at: attrs["end"],
51
- status: attrs["status"],
52
- timezone: attrs["timezone"],
53
- raw:,
54
- )
55
-
56
- Match.new(start_pos: pos, end_pos:, node:)
57
- end
58
-
59
- private
60
- end
61
- end
62
- end
63
- end
64
- end
@@ -1,57 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- module Detectors
7
- # Detects user and group mentions (@username, @groupname).
8
- #
9
- # @example Basic usage
10
- # detector = Mention.new
11
- # match = detector.detect("Hello @gerhard!", 6)
12
- # match.node.name # => "gerhard"
13
- # match.node.type # => :user (default)
14
- #
15
- # @example With type resolver
16
- # resolver = ->(name) { name == "Testers" ? :group : :user }
17
- # detector = Mention.new(type_resolver: resolver)
18
- # match = detector.detect("@Testers", 0)
19
- # match.node.type # => :group
20
- class Mention < Base
21
- # @param type_resolver [#call, nil] callable that takes a name and returns :user or :group
22
- def initialize(type_resolver: nil)
23
- @type_resolver = type_resolver
24
- end
25
-
26
- # Attempt to detect a mention at the given position.
27
- #
28
- # @param input [String] the full input string
29
- # @param pos [Integer] current position to check
30
- # @return [Match, nil] match result or nil if no match
31
- def detect(input, pos)
32
- return nil unless input[pos] == "@"
33
- return nil unless word_boundary?(input, pos)
34
-
35
- # Extract the username/group name
36
- name = extract_word(input, pos + 1)
37
- return nil if name.empty?
38
-
39
- end_pos = pos + 1 + name.length
40
- type = resolve_type(name)
41
- node = AST::Mention.new(name:, type:)
42
-
43
- Match.new(start_pos: pos, end_pos:, node:)
44
- end
45
-
46
- private
47
-
48
- def resolve_type(name)
49
- return :user unless @type_resolver
50
-
51
- @type_resolver.call(name) || :user
52
- end
53
- end
54
- end
55
- end
56
- end
57
- end
@@ -1,52 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- module Detectors
7
- # Detects Discourse poll blocks [poll]...[/poll].
8
- #
9
- # @example
10
- # detector = Poll.new
11
- # input = "[poll type=\"regular\"]\n* A\n* B\n[/poll]"
12
- # match = detector.detect(input, 0)
13
- # match.node.type # => "regular"
14
- class Poll < Base
15
- TAG_PATTERN = %r{\A\[poll(?<attrs>[^\]]*)\](?<content>.*?)\[/poll\]}im
16
-
17
- # Attempt to detect a poll at the given position.
18
- #
19
- # @param input [String] the full input string
20
- # @param pos [Integer] current position to check
21
- # @return [Match, nil] match result or nil if no match
22
- def detect(input, pos)
23
- match = TAG_PATTERN.match(input[pos..])
24
- return nil unless match
25
-
26
- attrs = parse_attributes(match[:attrs])
27
- node =
28
- AST::Poll.new(
29
- name: attrs["name"] || "poll",
30
- type: attrs["type"],
31
- results: attrs["results"],
32
- public: attrs["public"] == "true",
33
- chart_type: attrs["charttype"],
34
- options: extract_options(match[:content]),
35
- raw: match[0],
36
- )
37
-
38
- Match.new(start_pos: pos, end_pos: pos + match.end(0), node:)
39
- end
40
-
41
- private
42
-
43
- OPTION_PATTERN = /\A\s*(?:\*\s|-\s|\d+\.\s+)(?<value>.+?)\s*\z/
44
-
45
- def extract_options(content)
46
- content.each_line.filter_map { |line| OPTION_PATTERN.match(line)&.[](:value) }
47
- end
48
- end
49
- end
50
- end
51
- end
52
- end
@@ -1,98 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Markbridge
4
- module Processors
5
- module DiscourseMarkdown
6
- module Detectors
7
- # Detects Discourse upload references using upload:// URLs.
8
- #
9
- # Supports two formats:
10
- # - Images: ![alt|dimensions](upload://sha1.ext)
11
- # - Attachments: [filename|attachment](upload://sha1.ext) (size)
12
- #
13
- # @example Image
14
- # detector = Upload.new
15
- # input = "![logo|64x64](upload://abc123.png)"
16
- # match = detector.detect(input, 0)
17
- # match.node.type # => :image
18
- #
19
- # @example Attachment
20
- # detector = Upload.new
21
- # input = "[doc.pdf|attachment](upload://xyz789.pdf) (1.2 MB)"
22
- # match = detector.detect(input, 0)
23
- # match.node.type # => :attachment
24
- class Upload < Base
25
- # Image: ![alt|dimensions](upload://sha1.ext)
26
- IMAGE_PATTERN =
27
- %r{\A!\[(?<alt>[^|\]]*)(?:\|(?<dimensions>[^\]]*))?\]\(upload://(?<url>[^)]+)\)}
28
-
29
- # Attachment: [filename|attachment](upload://sha1.ext) (size)
30
- ATTACHMENT_PATTERN =
31
- %r{
32
- \A
33
- \[(?<filename>[^|\]]*)\|attachment\]
34
- \(upload://(?<url>[^)]+)\)
35
- (?:\s*\((?<size>[^)]+)\))?
36
- }xi
37
-
38
- # Attempt to detect an upload at the given position.
39
- #
40
- # @param input [String] the full input string
41
- # @param pos [Integer] current position to check
42
- # @return [Match, nil] match result or nil if no match
43
- def detect(input, pos)
44
- remaining = input[pos..]
45
- case input[pos]
46
- when "!"
47
- detect_image(remaining, pos)
48
- when "["
49
- detect_attachment(remaining, pos)
50
- end
51
- end
52
-
53
- private
54
-
55
- def detect_image(remaining, pos)
56
- match = IMAGE_PATTERN.match(remaining)
57
- return nil unless match
58
-
59
- sha1, filename = parse_upload_url(match[:url])
60
- alt = match[:alt]
61
- alt = nil if alt.empty?
62
-
63
- # `type: :image` is omitted because it is AST::Upload's default -
64
- # passing it explicitly was an equivalent-mutation surface.
65
- node =
66
- AST::Upload.new(sha1:, filename:, alt:, dimensions: match[:dimensions], raw: match[0])
67
-
68
- Match.new(start_pos: pos, end_pos: pos + match[0].length, node:)
69
- end
70
-
71
- def detect_attachment(remaining, pos)
72
- match = ATTACHMENT_PATTERN.match(remaining)
73
- return nil unless match
74
-
75
- sha1, = parse_upload_url(match[:url])
76
-
77
- node =
78
- AST::Upload.new(
79
- sha1:,
80
- filename: match[:filename],
81
- type: :attachment,
82
- size: match[:size],
83
- raw: match[0],
84
- )
85
-
86
- Match.new(start_pos: pos, end_pos: pos + match[0].length, node:)
87
- end
88
-
89
- # URL format: sha1.ext or just sha1. Returns [sha1, filename-or-nil].
90
- def parse_upload_url(url_part)
91
- sha1, _, ext = url_part.partition(".")
92
- [sha1, ext.empty? ? nil : url_part]
93
- end
94
- end
95
- end
96
- end
97
- end
98
- end