rdeck 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: da13cdf307d357991ee63bb6b0809c37c72aad4bc535b0e3b2daaccf17af17d7
4
+ data.tar.gz: 224b8810ab80919eb2f84c27046d11bfa2ea71b80e4be8ff0f9c241291546864
5
+ SHA512:
6
+ metadata.gz: 935940ebb52fb91f3c7fdb731be78e590de4fb307211e4d28a77d2b20277075aed4fae17cad1cfb63deb9fa0a6e9f26a6a398889befb7805a5b2e6f9160f5176
7
+ data.tar.gz: f2fc4b8a1238cf719940dd2f99e4cedd60139e01d179378ab2f11d9a47dde3b862019360821acea77e3966a4fd8aaf17dcf8a42dd202d095839894fbaecfa488
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.1.0 - 2025-12-27
6
+
7
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,290 @@
1
+ # Rdeck
2
+
3
+ Rdeck is a Ruby CLI tool that converts Markdown presentations to Google Slides, enabling continuous deck creation with content and design separation.
4
+
5
+ This is a Ruby implementation of the [deck](https://github.com/k1LoW/deck) tool originally written in Go.
6
+
7
+ ## Features
8
+
9
+ - Convert Markdown files to Google Slides presentations
10
+ - Support for CommonMark and GitHub Flavored Markdown (GFM)
11
+ - Automatic slide layout detection
12
+ - Support for images, tables, and block quotes
13
+ - Code blocks with syntax highlighting (via external command)
14
+ - Incremental updates - only modify changed slides
15
+ - File watching mode for live updates
16
+ - Custom style support
17
+ - Multiple authentication methods (OAuth2, Service Account, ADC)
18
+ - Profile support for multiple Google accounts
19
+
20
+ ## Installation
21
+
22
+ Add this line to your application's Gemfile:
23
+
24
+ ```ruby
25
+ gem 'rdeck'
26
+ ```
27
+
28
+ And then execute:
29
+
30
+ ```bash
31
+ $ bundle install
32
+ ```
33
+
34
+ Or install it yourself as:
35
+
36
+ ```bash
37
+ $ gem install rdeck
38
+ ```
39
+
40
+ ## Prerequisites
41
+
42
+ ### Google Cloud Setup
43
+
44
+ 1. Create a project in [Google Cloud Console](https://console.cloud.google.com/)
45
+ 2. Enable the Google Slides API and Google Drive API
46
+ 3. Create OAuth2 credentials or a Service Account
47
+ 4. Download the credentials file
48
+
49
+ ### System Dependencies
50
+
51
+ - Ruby 3.1.0 or higher
52
+ - libvips (for image processing)
53
+
54
+ On macOS:
55
+ ```bash
56
+ brew install vips
57
+ ```
58
+
59
+ On Ubuntu/Debian:
60
+ ```bash
61
+ sudo apt-get install libvips-dev
62
+ ```
63
+
64
+ ## Usage
65
+
66
+ ### Create a New Presentation
67
+
68
+ ```bash
69
+ # Create a blank presentation
70
+ $ rdeck new
71
+
72
+ # Create with a title and save ID to markdown file
73
+ $ rdeck new deck.md -t "My Presentation"
74
+
75
+ # Create from a base template
76
+ $ rdeck new deck.md -t "My Presentation" -b <base-presentation-id>
77
+ ```
78
+
79
+ ### Apply Markdown to Slides
80
+
81
+ ```bash
82
+ # Apply markdown to presentation
83
+ $ rdeck apply deck.md
84
+
85
+ # Specify presentation ID via command line
86
+ $ rdeck apply deck.md -i <presentation-id>
87
+
88
+ # Apply specific pages
89
+ $ rdeck apply deck.md -p 1,3-5
90
+
91
+ # Watch mode - automatically apply on file changes
92
+ $ rdeck apply deck.md -w
93
+ ```
94
+
95
+ ### Other Commands
96
+
97
+ ```bash
98
+ # List your presentations
99
+ $ rdeck ls
100
+
101
+ # List available layouts
102
+ $ rdeck ls-layouts deck.md
103
+
104
+ # Open presentation in browser
105
+ $ rdeck open deck.md
106
+
107
+ # Export to PDF
108
+ $ rdeck export deck.md -o presentation.pdf
109
+
110
+ # Check environment and configuration
111
+ $ rdeck doctor
112
+ ```
113
+
114
+ ## Markdown Format
115
+
116
+ ### Basic Structure
117
+
118
+ ```markdown
119
+ ---
120
+ presentationID: your-presentation-id
121
+ title: My Presentation
122
+ ---
123
+
124
+ # First Slide Title
125
+
126
+ This is the content of the first slide.
127
+
128
+ ---
129
+
130
+ ## Second Slide
131
+
132
+ - Bullet point 1
133
+ - Bullet point 2
134
+ - Nested bullet
135
+
136
+ ---
137
+
138
+ # Slide with Image
139
+
140
+ ![alt text](path/to/image.png)
141
+
142
+ ---
143
+
144
+ # Slide with Table
145
+
146
+ | Header 1 | Header 2 |
147
+ |----------|----------|
148
+ | Cell 1 | Cell 2 |
149
+ | Cell 3 | Cell 4 |
150
+ ```
151
+
152
+ ### Frontmatter Options
153
+
154
+ ```yaml
155
+ ---
156
+ presentationID: your-presentation-id # Required for apply
157
+ title: My Presentation
158
+ breaks: true # Treat single line breaks as <br>
159
+ codeBlockToImageCommand: "command to convert code"
160
+ defaults:
161
+ - if: page == 1
162
+ layout: title
163
+ - if: titles.size() == 0
164
+ layout: blank
165
+ ---
166
+ ```
167
+
168
+ ### Page Configuration
169
+
170
+ Use HTML comments with JSON to configure individual pages:
171
+
172
+ ```markdown
173
+ <!-- {"layout": "title-and-body"} -->
174
+ <!-- {"freeze": true} -->
175
+ <!-- {"skip": true} -->
176
+
177
+ # Slide Title
178
+
179
+ Content here
180
+
181
+ <!-- This is a speaker note -->
182
+ ```
183
+
184
+ ### Supported Markdown Features
185
+
186
+ - **Headers** (H1-H6): Mapped to title, subtitle, or body based on hierarchy
187
+ - **Bold** and *italic* text
188
+ - [Links](https://example.com)
189
+ - `Inline code`
190
+ - Bullet lists (ordered and unordered)
191
+ - Nested lists
192
+ - > Block quotes
193
+ - Tables
194
+ - Images
195
+ - ~~Strikethrough~~
196
+ - Horizontal rules (for page breaks: `---`)
197
+
198
+ ## Configuration
199
+
200
+ Configuration files are located at:
201
+ - `~/.config/rdeck/config.yml`
202
+ - `~/.config/rdeck/config-{profile}.yml` (for profiles)
203
+
204
+ Example configuration:
205
+
206
+ ```yaml
207
+ # Base presentation to copy from
208
+ basePresentationID: "abc123..."
209
+
210
+ # Default folder for new presentations
211
+ folderID: "xyz789..."
212
+
213
+ # Default line break behavior
214
+ breaks: true
215
+
216
+ # Command to convert code blocks to images
217
+ codeBlockToImageCommand: "code2img"
218
+
219
+ # Default conditions for layout selection
220
+ defaults:
221
+ - if: page == 1
222
+ layout: title
223
+ - if: titles.size() == 1 && headings[2].size() == 1
224
+ layout: section
225
+ ```
226
+
227
+ ## Authentication
228
+
229
+ ### OAuth2 (Default)
230
+
231
+ 1. Download OAuth2 credentials from Google Cloud Console
232
+ 2. Save to `~/.local/share/rdeck/credentials.json`
233
+ 3. Run any rdeck command - you'll be prompted to authorize
234
+
235
+ ### Service Account
236
+
237
+ ```bash
238
+ export RDECK_SERVICE_ACCOUNT_KEY='{ "type": "service_account", ... }'
239
+ rdeck apply deck.md
240
+ ```
241
+
242
+ ### Application Default Credentials
243
+
244
+ ```bash
245
+ export RDECK_ENABLE_ADC=1
246
+ rdeck apply deck.md
247
+ ```
248
+
249
+ ### Access Token
250
+
251
+ ```bash
252
+ export RDECK_ACCESS_TOKEN="ya29...."
253
+ rdeck apply deck.md
254
+ ```
255
+
256
+ ## Profiles
257
+
258
+ Use profiles to manage multiple Google accounts:
259
+
260
+ ```bash
261
+ # Use a specific profile
262
+ rdeck apply deck.md --profile work
263
+
264
+ # Profile files
265
+ ~/.config/rdeck/config-work.yml
266
+ ~/.local/share/rdeck/credentials-work.json
267
+ ~/.local/state/rdeck/token-work.json
268
+ ```
269
+
270
+ ## Development
271
+
272
+ After checking out the repo, run:
273
+
274
+ ```bash
275
+ bundle install
276
+ bundle exec rake spec # Run tests
277
+ bundle exec rdeck # Run CLI
278
+ ```
279
+
280
+ ## Contributing
281
+
282
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ydah/rdeck.
283
+
284
+ ## License
285
+
286
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
287
+
288
+ ## Acknowledgments
289
+
290
+ This project is a Ruby port of [deck](https://github.com/k1LoW/deck) by k1LoW. The original Go implementation provided the specification and inspiration for this Ruby version.
data/exe/rdeck ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path('../lib', __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+
7
+ require 'rdeck'
8
+
9
+ Rdeck::CLI.start(ARGV)
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ class ActionGenerator
5
+ Action = Struct.new(:type, :index, :move_to_index, :slide, keyword_init: true)
6
+
7
+ # Similarity scoring constants
8
+ SCORE_EXACT_MATCH = 500
9
+ SCORE_LAYOUT_MATCH = 50
10
+ SCORE_TITLES_MATCH = 80
11
+ SCORE_SUBTITLES_MATCH = 20
12
+ SCORE_BODIES_MATCH = 160
13
+ SCORE_IMAGES_MATCH = 40
14
+ SCORE_BLOCK_QUOTES_MATCH = 30
15
+ SCORE_TABLES_MATCH = 35
16
+ SCORE_SAME_POSITION_WITH_LAYOUT = 8
17
+ SCORE_EARLIER_POSITION_WITH_LAYOUT = 6
18
+ SCORE_LATER_POSITION_WITH_LAYOUT = 4
19
+ SCORE_FALLBACK_WITH_LAYOUT = 2
20
+ SCORE_SAME_POSITION_WITHOUT_LAYOUT = 4
21
+ SCORE_LATER_POSITION_WITHOUT_LAYOUT = 2
22
+
23
+ def generate(before, after)
24
+ before_copy = deep_copy(before)
25
+ after_copy = deep_copy(after)
26
+
27
+ adjusted_before, adjusted_after = adjust_slide_count(before_copy, after_copy)
28
+
29
+ apply_freeze_marks(adjusted_before, adjusted_after)
30
+
31
+ mapping = map_slides(adjusted_before, adjusted_after)
32
+
33
+ apply_delete_marks(adjusted_before, adjusted_after, mapping)
34
+
35
+ actions = []
36
+
37
+ actions += generate_append_actions(adjusted_before)
38
+
39
+ actions += generate_update_actions(adjusted_before, adjusted_after, mapping)
40
+
41
+ actions += generate_delete_actions(adjusted_before, mapping)
42
+
43
+ cleaned_after = remove_delete_marked(adjusted_after)
44
+ actions += generate_move_actions(adjusted_before, cleaned_after, mapping)
45
+
46
+ actions
47
+ end
48
+
49
+ private
50
+
51
+ def deep_copy(slides)
52
+ slides.map(&:dup)
53
+ end
54
+
55
+ def adjust_slide_count(before, after)
56
+ size_diff = after.size - before.size
57
+
58
+ if size_diff.positive?
59
+ size_diff.times do
60
+ slide = Slide.new
61
+ slide.new_slide = true
62
+ before << slide
63
+ end
64
+ elsif size_diff.negative?
65
+ (-size_diff).times do
66
+ after << Slide.new
67
+ end
68
+ end
69
+
70
+ [before, after]
71
+ end
72
+
73
+ def apply_freeze_marks(before, after)
74
+ before.each_with_index do |slide, i|
75
+ after[i].freeze = true if slide.freeze
76
+ end
77
+ end
78
+
79
+ def map_slides(before, after)
80
+ n = before.size
81
+ return {} if n.zero?
82
+
83
+ similarity_matrix = Array.new(n) { Array.new(n, 0) }
84
+ n.times do |i|
85
+ n.times do |j|
86
+ similarity_matrix[i][j] = calculate_similarity(before[i], after[j], i, j)
87
+ end
88
+ end
89
+
90
+ require_relative 'hungarian'
91
+ hungarian = Hungarian.new(similarity_matrix)
92
+ hungarian.solve
93
+ end
94
+
95
+ def calculate_similarity(slide1, slide2, idx1, idx2)
96
+ return SCORE_EXACT_MATCH if slide1 == slide2
97
+
98
+ score = 0
99
+
100
+ if slide1.layout == slide2.layout && !slide1.layout.empty?
101
+ score += SCORE_LAYOUT_MATCH
102
+
103
+ score += SCORE_TITLES_MATCH if slide1.titles == slide2.titles && !slide1.titles.empty?
104
+ score += SCORE_SUBTITLES_MATCH if slide1.subtitles == slide2.subtitles && !slide1.subtitles.empty?
105
+ score += SCORE_BODIES_MATCH if slide1.bodies == slide2.bodies
106
+ score += SCORE_IMAGES_MATCH if images_equivalent?(slide1.images, slide2.images)
107
+ score += SCORE_BLOCK_QUOTES_MATCH if slide1.block_quotes == slide2.block_quotes
108
+ score += SCORE_TABLES_MATCH if slide1.tables == slide2.tables
109
+ end
110
+
111
+ if slide1.layout == slide2.layout && !slide1.layout.empty?
112
+ score += if idx1 == idx2
113
+ SCORE_SAME_POSITION_WITH_LAYOUT
114
+ elsif idx2 < idx1
115
+ SCORE_EARLIER_POSITION_WITH_LAYOUT
116
+ elsif idx1 < idx2
117
+ SCORE_LATER_POSITION_WITH_LAYOUT
118
+ else
119
+ SCORE_FALLBACK_WITH_LAYOUT
120
+ end
121
+ elsif idx1 == idx2
122
+ score += SCORE_SAME_POSITION_WITHOUT_LAYOUT
123
+ elsif idx1 < idx2
124
+ score += SCORE_LATER_POSITION_WITHOUT_LAYOUT
125
+ end
126
+
127
+ score
128
+ end
129
+
130
+ def images_equivalent?(images1, images2)
131
+ return true if images1.size != images2.size
132
+
133
+ images1.zip(images2).all? { |img1, img2| img1&.equivalent?(img2) }
134
+ end
135
+
136
+ def apply_delete_marks(before, after, mapping)
137
+ mapping.each do |before_idx, after_idx|
138
+ before[before_idx].delete_slide = if after[after_idx].freeze
139
+ false
140
+ elsif before[before_idx].new_slide
141
+ false
142
+ else
143
+ after[after_idx].empty?
144
+ end
145
+ end
146
+ end
147
+
148
+ def generate_append_actions(before)
149
+ before.select { |s| s.new_slide && !s.delete_slide }
150
+ .map { |s| Action.new(type: :append, slide: s) }
151
+ end
152
+
153
+ def generate_update_actions(before, after, mapping)
154
+ actions = []
155
+
156
+ mapping.each do |before_idx, after_idx|
157
+ next if before[before_idx].delete_slide
158
+ next if after[after_idx].freeze
159
+
160
+ actions << Action.new(type: :update, index: before_idx, slide: after[after_idx])
161
+ end
162
+
163
+ actions
164
+ end
165
+
166
+ def generate_delete_actions(before, _mapping)
167
+ before.each_with_index.select { |slide, _| slide.delete_slide }
168
+ .map { |_, idx| Action.new(type: :delete, index: idx) }
169
+ end
170
+
171
+ def generate_move_actions(_before, _after, _mapping)
172
+ []
173
+ end
174
+
175
+ def remove_delete_marked(slides)
176
+ slides.reject(&:delete_slide)
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ class Apply
5
+ attr_reader :presentation
6
+
7
+ def initialize(presentation)
8
+ @presentation = presentation
9
+ @request_builder = nil
10
+ @image_uploader = nil
11
+ end
12
+
13
+ def apply(slides, pages: nil, folder_id: nil)
14
+ pages ||= (1..slides.size).to_a
15
+
16
+ presentation.refresh
17
+ initialize_helpers(folder_id)
18
+
19
+ before = presentation.current_slides
20
+ after = build_after_slides(before, slides, pages)
21
+
22
+ generator = ActionGenerator.new
23
+ actions = generator.generate(before, after)
24
+
25
+ preload_images(actions)
26
+
27
+ execute_actions(actions)
28
+
29
+ presentation.refresh
30
+ ensure
31
+ @image_uploader&.cleanup
32
+ end
33
+
34
+ private
35
+
36
+ def initialize_helpers(folder_id)
37
+ require_relative 'request_builder'
38
+ require_relative 'image_uploader'
39
+ @request_builder = RequestBuilder.new(presentation)
40
+ @image_uploader = ImageUploader.new(presentation.drive_service, folder_id:)
41
+ end
42
+
43
+ def slide_layout_name(slide)
44
+ slide&.slide_properties&.layout_object_id&.then do |layout_id|
45
+ presentation_data = presentation.instance_variable_get(:@presentation)
46
+ presentation_data&.layouts&.find { _1.object_id_prop == layout_id }&.layout_properties&.display_name
47
+ end
48
+ end
49
+
50
+ def build_after_slides(before, slides, pages)
51
+ before.dup.tap do |after|
52
+ pages.each_with_index do |page, idx|
53
+ slide = slides[page - 1]
54
+ next unless slide
55
+
56
+ idx < after.size ? after[idx] = slide : after << slide
57
+ end
58
+ end
59
+ end
60
+
61
+ def preload_images(actions)
62
+ actions
63
+ .filter { %i[append update].include?(_1.type) && _1.slide }
64
+ .flat_map { _1.slide.images || [] }
65
+ .filter(&:from_markdown)
66
+ .each { @image_uploader.upload(_1) }
67
+ end
68
+
69
+ def execute_actions(actions)
70
+ state = ExecutionState.new(presentation.current_slides.size)
71
+
72
+ actions.each do |action|
73
+ case action
74
+ in { type: :append }
75
+ handle_append(action, state)
76
+ in { type: :update }
77
+ handle_update(action, state)
78
+ in { type: :delete }
79
+ state.delete_indices << action.index
80
+ in { type: :move }
81
+ handle_move(action, state)
82
+ end
83
+ end
84
+
85
+ presentation.batch_update(state.all_requests)
86
+
87
+ presentation.delete_slides(state.delete_indices.sort.reverse) unless state.delete_indices.empty?
88
+ end
89
+
90
+ def handle_append(action, state)
91
+ layout = action.slide.layout.empty? ? 'BLANK' : action.slide.layout
92
+ index = state.current_slide_count + state.append_count
93
+
94
+ presentation.create_slide(index, layout)
95
+
96
+ presentation.refresh
97
+ new_slide = current_slide_at(index)
98
+ requests = @request_builder.build_slide_update_requests(index, action.slide, new_slide)
99
+ state.appending_requests.concat(requests.compact)
100
+
101
+ state.append_count += 1
102
+ end
103
+
104
+ def handle_update(action, state)
105
+ presentation.refresh
106
+ existing_slide = current_slide_at(action.index)
107
+
108
+ current_layout = slide_layout_name(existing_slide)
109
+ requested_layout = action.slide.layout
110
+
111
+ if layout_change_needed?(current_layout, requested_layout)
112
+ recreate_slide_with_new_layout(action, state, current_layout, requested_layout)
113
+ else
114
+ requests = @request_builder.build_slide_update_requests(action.index, action.slide, existing_slide)
115
+ state.update_requests.concat(requests.compact)
116
+ end
117
+ end
118
+
119
+ def handle_move(action, state)
120
+ flush_pending_requests(state)
121
+ presentation.move_slide(action.index, action.move_to_index)
122
+ end
123
+
124
+ def layout_change_needed?(current, requested)
125
+ requested && !requested.empty? && current != requested
126
+ end
127
+
128
+ def recreate_slide_with_new_layout(action, state, current_layout, requested_layout)
129
+ warn "Recreating slide #{action.index} to change layout from '#{current_layout}' to '#{requested_layout}'"
130
+
131
+ flush_pending_requests(state)
132
+
133
+ presentation.delete_slides([action.index])
134
+
135
+ presentation.create_slide(action.index, requested_layout)
136
+
137
+ presentation.refresh
138
+ new_slide = current_slide_at(action.index)
139
+ requests = @request_builder.build_slide_update_requests(action.index, action.slide, new_slide)
140
+
141
+ presentation.batch_update(requests.compact)
142
+ end
143
+
144
+ def flush_pending_requests(state)
145
+ presentation.batch_update(state.all_requests)
146
+ state.clear_requests
147
+ end
148
+
149
+ def current_slide_at(index) = presentation.instance_variable_get(:@presentation).slides[index]
150
+
151
+ class ExecutionState
152
+ attr_accessor :append_count
153
+ attr_reader :appending_requests, :update_requests, :delete_indices, :current_slide_count
154
+
155
+ def initialize(current_slide_count)
156
+ @current_slide_count = current_slide_count
157
+ @append_count = 0
158
+ @appending_requests = []
159
+ @update_requests = []
160
+ @delete_indices = []
161
+ end
162
+
163
+ def all_requests = appending_requests + update_requests
164
+
165
+ def clear_requests
166
+ @appending_requests = []
167
+ @update_requests = []
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdeck
4
+ class BlockQuote
5
+ attr_accessor :paragraphs, :nesting
6
+
7
+ def initialize(paragraphs: [], nesting: 0)
8
+ @paragraphs = paragraphs
9
+ @nesting = nesting
10
+ end
11
+
12
+ def ==(other)
13
+ return false unless other.is_a?(BlockQuote)
14
+
15
+ paragraphs == other.paragraphs &&
16
+ nesting == other.nesting
17
+ end
18
+
19
+ def text
20
+ paragraphs.map(&:text).join("\n")
21
+ end
22
+
23
+ def empty?
24
+ paragraphs.empty? || paragraphs.all?(&:empty?)
25
+ end
26
+ end
27
+ end