booksmith 1.0.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,56 @@
1
+ module Booksmith
2
+ module FontPaths
3
+ NOTO_SANS_FILES = {
4
+ normal: 'NotoSans-Regular.ttf',
5
+ bold: 'NotoSans-Bold.ttf',
6
+ italic: 'NotoSans-Italic.ttf',
7
+ bold_italic: 'NotoSans-BoldItalic.ttf'
8
+ }.freeze
9
+
10
+ NOTO_SANS_MONO_FILES = {
11
+ normal: 'NotoSansMono-Regular.ttf',
12
+ bold: 'NotoSansMono-Bold.ttf'
13
+ }.freeze
14
+
15
+ def self.search_dirs
16
+ [
17
+ File.join(Dir.pwd, 'assets', 'fonts'),
18
+ File.expand_path('../fonts', __dir__),
19
+ File.expand_path('~/Library/Fonts'),
20
+ '/usr/share/fonts/noto',
21
+ '/usr/share/fonts/truetype/noto',
22
+ '/usr/local/share/fonts/noto',
23
+ *Dir.glob('/opt/homebrew/Caskroom/font-noto-sans/*/NotoSans/unhinted/ttf'),
24
+ *Dir.glob('/opt/homebrew/Caskroom/font-noto-sans-mono/*/NotoSansMono/unhinted/ttf'),
25
+ *Dir.glob('/usr/local/Caskroom/font-noto-sans/*/NotoSans/unhinted/ttf'),
26
+ *Dir.glob('/usr/local/Caskroom/font-noto-sans-mono/*/NotoSansMono/unhinted/ttf')
27
+ ]
28
+ end
29
+
30
+ def self.resolve(filename)
31
+ search_dirs.each do |dir|
32
+ path = File.join(dir, filename)
33
+ return path if File.file?(path)
34
+ end
35
+
36
+ raise <<~MSG.strip
37
+ #{filename} not found. Install Noto fonts (e.g. `brew install --cask font-noto-sans font-noto-sans-mono`) or add font files to assets/fonts/.
38
+ MSG
39
+ end
40
+
41
+ def self.families
42
+ {
43
+ 'Noto Sans' => NOTO_SANS_FILES.transform_values { |file| resolve(file) },
44
+ 'Noto Sans Mono' => NOTO_SANS_MONO_FILES.transform_values { |file| resolve(file) }
45
+ }
46
+ end
47
+
48
+ def self.all_files
49
+ families.values.flat_map(&:values).uniq
50
+ end
51
+
52
+ def self.register(pdf)
53
+ pdf.font_families.update(families)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,97 @@
1
+ module Booksmith
2
+ module Headers
3
+ private
4
+
5
+ SUBTITLE_COMMENT = /\A<!--\s*subtitle:\s*(.*?)\s*-->\z/i
6
+ PAGE_BREAK_COMMENT = /\A<!--\s*PAGE_BREAK\s*-->\z/i
7
+
8
+ # Kramdown requires a blank line between consecutive headers.
9
+ def self.normalize_consecutive_headers(content)
10
+ lines = content.lines
11
+ normalized = []
12
+ lines.each_with_index do |line, idx|
13
+ normalized << line
14
+ next_line = lines[idx + 1]
15
+ if line.match?(/\A\#{1,6}\s/) && next_line&.match?(/\A\#{1,6}\s/)
16
+ normalized << "\n"
17
+ end
18
+ end
19
+ normalized.join
20
+ end
21
+
22
+ def normalize_consecutive_headers(content)
23
+ Headers.normalize_consecutive_headers(content)
24
+ end
25
+
26
+ def subtitle_comment?(el)
27
+ el&.type == :xml_comment && el.value.to_s =~ SUBTITLE_COMMENT
28
+ end
29
+
30
+ def subtitle_comment_text(el)
31
+ el.value.to_s[SUBTITLE_COMMENT, 1]&.strip
32
+ end
33
+
34
+ def render_subtitle(text)
35
+ size = @config.fetch(:subtitle_font_size, @config[:h4_font_size])
36
+ style = @config.fetch(:subtitle_bold, false) ? :bold : nil
37
+ font_name = @config[:subtitle_font]
38
+ if font_name
39
+ @pdf.font(font_name) do
40
+ @pdf.text text, size: size, style: style
41
+ end
42
+ else
43
+ @pdf.text text, size: size, style: style
44
+ end
45
+ end
46
+
47
+ def render_header(el, prev:, next_el:)
48
+ prev_is_page_break = prev&.type == :xml_comment && prev.value.to_s =~ PAGE_BREAK_COMMENT
49
+ if prev&.type == :header || prev_is_page_break
50
+ @pdf.move_down 0
51
+ else
52
+ @pdf.move_down 20
53
+ end
54
+
55
+ level = el.options[:level]
56
+
57
+ header_sizes = {
58
+ 1 => @config[:h1_font_size],
59
+ 2 => @config[:h2_font_size],
60
+ 3 => @config[:h3_font_size],
61
+ 4 => @config[:h4_font_size]
62
+ }
63
+
64
+ size = header_sizes[level]
65
+ raise KeyError, "Missing required header font size config for H#{level} (:h#{level}_font_size)" unless size
66
+
67
+ font_name = @config[:header_font]
68
+ if font_name
69
+ @pdf.font(font_name) do
70
+ @pdf.text el.options[:raw_text], size: size, style: :bold
71
+ end
72
+ else
73
+ @pdf.text el.options[:raw_text], size: size, style: :bold
74
+ end
75
+
76
+ title_spacing_after = @config.fetch(:title_spacing_after, 20)
77
+ if level == 1 && subtitle_comment?(next_el)
78
+ @pdf.move_down 0
79
+ elsif next_el&.type == :header
80
+ @pdf.move_down 0
81
+ elsif level == 1
82
+ @pdf.move_down title_spacing_after
83
+ else
84
+ @pdf.move_down 14
85
+ end
86
+ end
87
+
88
+ def render_xml_comment(el, prev:)
89
+ if el.value.to_s =~ PAGE_BREAK_COMMENT
90
+ @pdf.start_new_page
91
+ elsif prev&.type == :header && prev.options[:level] == 1 && (text = subtitle_comment_text(el))
92
+ render_subtitle(text)
93
+ @pdf.move_down @config.fetch(:subtitle_spacing_after, 10)
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,92 @@
1
+ require 'digest/md5'
2
+ require 'fileutils'
3
+
4
+ module Booksmith
5
+ module Images
6
+ private
7
+
8
+ def image_child(el)
9
+ non_blank = el.children.reject { |c| c.type == :blank || (c.type == :text && c.value.strip.empty?) }
10
+ return nil unless non_blank.length == 1 && non_blank.first.type == :img
11
+ non_blank.first
12
+ end
13
+
14
+ def render_image(img_el)
15
+ src = img_el.attr['src']
16
+ path = File.expand_path(src, Dir.pwd)
17
+ return unless File.exist?(path)
18
+
19
+ opts = img_el.attr['title'].to_s
20
+ margin_top = opts[/top=(\d+(?:\.\d+)?)/, 1]&.to_f || 0
21
+ margin_bottom = opts[/bottom=(\d+(?:\.\d+)?)/, 1]&.to_f || 0
22
+ width = opts[/width=(\d+(?:\.\d+)?)/, 1]&.to_f
23
+ flatten_bg = image_flatten_bg_color(bg_override: opts[/bg=([0-9A-Fa-f]{6})/, 1])
24
+
25
+ @pdf.move_down margin_top if margin_top > 0
26
+ max_w = width || @pdf.bounds.width
27
+ max_h = @pdf.cursor - 20
28
+ @pdf.image(prepare_png_path(path, background_color: flatten_bg), fit: [max_w, max_h], position: :center)
29
+ @pdf.move_down margin_bottom if margin_bottom > 0
30
+ end
31
+
32
+ def image_flatten_bg_color(bg_override: nil)
33
+ return bg_override if bg_override
34
+ return @image_flatten_bg if @image_flatten_bg
35
+
36
+ @config.fetch(:image_flatten_bg_color, 'FFFFFF')
37
+ end
38
+
39
+ def blockquote_image_flatten_color(style_name)
40
+ flatten_key = :"#{style_name}_image_flatten_bg_color"
41
+ return @config[flatten_key] if @config.key?(flatten_key)
42
+
43
+ blockquote_style_value(style_name, :bg_color, 'EEEEEE')
44
+ end
45
+
46
+ def with_image_flatten_bg(color)
47
+ previous = @image_flatten_bg
48
+ @image_flatten_bg = color
49
+ yield
50
+ ensure
51
+ @image_flatten_bg = previous
52
+ end
53
+
54
+ def magick_available?
55
+ return @magick_available unless @magick_available.nil?
56
+
57
+ @magick_available = system('which magick > /dev/null 2>&1')
58
+ end
59
+
60
+ # Some PNG exports bake a visible checkerboard into opaque pixels instead of
61
+ # using a real alpha channel. Flatten those onto the target background.
62
+ def prepare_png_path(path, background_color:)
63
+ return path unless path.downcase.end_with?('.png')
64
+ return path unless magick_available?
65
+
66
+ bg_hex = background_color.to_s.delete_prefix('#').upcase
67
+ cache_dir = File.join(Dir.pwd, '.booksmith-cache', 'images')
68
+ FileUtils.mkdir_p(cache_dir)
69
+
70
+ cache_name = "#{Digest::MD5.hexdigest(path)}_#{bg_hex}_#{File.mtime(path).to_i}.png"
71
+ cached = File.join(cache_dir, cache_name)
72
+ return cached if File.exist?(cached)
73
+
74
+ success = system(
75
+ 'magick', path,
76
+ '-fuzz', '12%',
77
+ '-transparent', 'white',
78
+ '-transparent', '#EBEBEB',
79
+ '-transparent', '#EAEAEA',
80
+ '-background', "##{bg_hex}",
81
+ '-alpha', 'remove',
82
+ '-compose', 'Copy',
83
+ '-flatten',
84
+ cached,
85
+ out: File::NULL,
86
+ err: File::NULL
87
+ )
88
+
89
+ success && File.exist?(cached) ? cached : path
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,62 @@
1
+ module Booksmith
2
+ module Lists
3
+ private
4
+
5
+ def render_list(el)
6
+ list_indent = list_indent_for(el.type)
7
+ list_bullet_offset = @config.fetch(:list_bullet_offset, 10)
8
+ list_marker_gap = @config.fetch(:list_marker_gap, 4).to_f
9
+ list_item_spacing = @config.fetch(:list_item_spacing, 6)
10
+ list_margin_top = @config.fetch(:list_margin_top, 8)
11
+ list_margin_bottom = @config.fetch(:list_margin_bottom, 8)
12
+
13
+ @list_level += 1
14
+ @pdf.move_down list_margin_top if @list_level == 1
15
+
16
+ el.children.each_with_index do |item, index|
17
+ marker = el.type == :ul ? '•' : "#{index + 1}."
18
+ indent = @list_level * list_indent
19
+
20
+ @pdf.indent(indent) do
21
+ ensure_space_for_list_item
22
+ render_list_marker(marker, list_bullet_offset, align: el.type == :ul ? :center : :right)
23
+ @pdf.indent(list_bullet_offset + list_marker_gap) { convert_element(item) }
24
+ end
25
+ @pdf.move_down list_item_spacing unless index == el.children.length - 1
26
+ end
27
+
28
+ @list_level -= 1
29
+ @pdf.move_down list_margin_bottom if @list_level == 0
30
+ end
31
+
32
+ def render_list_item(el)
33
+ el.children.each { |child| convert_element(child) }
34
+ end
35
+
36
+ def list_indent_for(list_type)
37
+ key = list_type == :ul ? :list_bullet_indent : :list_number_indent
38
+ @config.fetch(key, @config.fetch(:list_indent, 18)).to_f
39
+ end
40
+
41
+ def ensure_space_for_list_item
42
+ font_size = @config[:font_size].to_f
43
+ min_height = font_size * @config[:line_height].to_f
44
+ @pdf.start_new_page if @pdf.cursor <= @pdf.bounds.bottom + min_height
45
+ end
46
+
47
+ def render_list_marker(marker, offset, align: :right)
48
+ font_size = @config[:font_size].to_f
49
+ body_font = @config.fetch(:body_font, 'Noto Sans')
50
+
51
+ @pdf.font(body_font, size: font_size) do
52
+ @pdf.formatted_text_box(
53
+ [{ text: marker, size: font_size, font: body_font }],
54
+ at: [@pdf.bounds.left, @pdf.cursor],
55
+ width: offset,
56
+ align: align,
57
+ leading: @config[:line_height]
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,56 @@
1
+ module Booksmith
2
+ module Text
3
+ SMART_QUOTES = {
4
+ ldquo: "\u201c", rdquo: "\u201d",
5
+ lsquo: "\u2018", rsquo: "\u2019",
6
+ mdash: "\u2014", ndash: "\u2013",
7
+ laquo: "\u00ab", raquo: "\u00bb"
8
+ }.freeze
9
+
10
+ private
11
+
12
+ def extract_text(element, styles: nil)
13
+ formatted_text = []
14
+
15
+ element.children.each do |child|
16
+ case child.type
17
+ when :text
18
+ entry = { text: child.value }
19
+ entry[:styles] = styles if styles && !styles.empty?
20
+ formatted_text << entry
21
+ when :strong
22
+ new_styles = (styles || []) + [:bold]
23
+ formatted_text.concat(extract_text(child, styles: new_styles))
24
+ when :em
25
+ new_styles = (styles || []) + [:italic]
26
+ formatted_text.concat(extract_text(child, styles: new_styles))
27
+ when :codespan
28
+ entry = { text: child.value, font: 'Noto Sans Mono', size: @config[:font_size] - 2 }
29
+ entry[:styles] = styles if styles && !styles.empty?
30
+ formatted_text << entry
31
+ when :smart_quote
32
+ char = SMART_QUOTES[child.value] || '?'
33
+ entry = { text: char }
34
+ entry[:styles] = styles if styles && !styles.empty?
35
+ formatted_text << entry
36
+ when :a
37
+ new_styles = (styles || []) + [:underline]
38
+ extract_text(child, styles: new_styles).each do |entry|
39
+ entry[:color] = '0000FF'
40
+ formatted_text << entry
41
+ end
42
+ else
43
+ # Ignore unsupported inline types for now
44
+ end
45
+ end
46
+
47
+ formatted_text
48
+ end
49
+
50
+ def plain_text(element)
51
+ return '' unless element
52
+
53
+ extract_text(element).map { |entry| entry[:text] }.join
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Booksmith
4
+ VERSION = '1.0.0'
5
+ end
data/lib/booksmith.rb ADDED
@@ -0,0 +1,218 @@
1
+ require 'commander'
2
+ require 'prawn'
3
+ require 'kramdown'
4
+ require 'fileutils'
5
+
6
+ require_relative 'booksmith/version'
7
+ require_relative 'booksmith/font_paths'
8
+ require_relative 'booksmith/headers'
9
+ require_relative 'booksmith/text'
10
+ require_relative 'booksmith/lists'
11
+ require_relative 'booksmith/blockquotes'
12
+ require_relative 'booksmith/images'
13
+ require_relative 'booksmith/book_generator'
14
+ require_relative 'booksmith/epub_generator'
15
+
16
+ module Booksmith
17
+ class PDFConverter
18
+ include Headers
19
+ include Text
20
+ include Lists
21
+ include Blockquotes
22
+ include Images
23
+
24
+ def initialize(pdf, config, image_flatten_bg: nil)
25
+ @pdf = pdf
26
+ @config = config
27
+ @list_level = 0
28
+ @image_flatten_bg = image_flatten_bg
29
+ end
30
+
31
+ def convert(content)
32
+ content = normalize_consecutive_headers(content)
33
+ doc = Kramdown::Document.new(content)
34
+ convert_element(doc.root)
35
+ end
36
+
37
+ private
38
+
39
+ def convert_element(el, prev: nil, next_el: nil)
40
+ case el.type
41
+ when :header then render_header(el, prev: prev, next_el: next_el)
42
+ when :xml_comment then render_xml_comment(el, prev: prev)
43
+ when :p then render_paragraph(el)
44
+ when :blockquote then render_blockquote_element(el)
45
+ when :codeblock then render_codeblock(el)
46
+ when :ul, :ol then render_list(el)
47
+ when :li then render_list_item(el)
48
+ when :text then nil
49
+ else render_children(el)
50
+ end
51
+ end
52
+
53
+ def render_paragraph(el)
54
+ img_el = image_child(el)
55
+ if img_el
56
+ render_image(img_el)
57
+ @pdf.move_down 10
58
+ return
59
+ end
60
+
61
+ @pdf.formatted_text extract_text(el), size: @config[:font_size], leading: @config[:line_height]
62
+ @pdf.move_down 10 unless @list_level > 0
63
+ end
64
+
65
+ def render_blockquote_element(el)
66
+ if exercise_blockquote?(el)
67
+ render_exercise_blockquote(el)
68
+ elsif alt_blockquote?(el)
69
+ render_blockquote(el, :alt_blockquote)
70
+ else
71
+ render_blockquote(el, :blockquote)
72
+ end
73
+ end
74
+
75
+ def render_codeblock(el)
76
+ @pdf.font('Noto Sans Mono') do
77
+ @pdf.formatted_text_box(
78
+ [{ text: el.value, size: @config[:font_size] - 2 }],
79
+ at: [@pdf.bounds.left + 20, @pdf.cursor],
80
+ width: @pdf.bounds.width - 40
81
+ )
82
+ end
83
+ @pdf.move_down(el.value.lines.count * (@config[:font_size] + 2) + 10)
84
+ end
85
+
86
+ def render_children(el)
87
+ el.children.each_with_index do |child, idx|
88
+ prev_idx = idx - 1
89
+ prev_child = nil
90
+ while prev_idx >= 0
91
+ candidate = el.children[prev_idx]
92
+ if candidate.type != :blank
93
+ prev_child = candidate
94
+ break
95
+ end
96
+ prev_idx -= 1
97
+ end
98
+
99
+ next_idx = idx + 1
100
+ next_child = nil
101
+ while next_idx < el.children.length
102
+ candidate = el.children[next_idx]
103
+ if candidate.type != :blank
104
+ next_child = candidate
105
+ break
106
+ end
107
+ next_idx += 1
108
+ end
109
+
110
+ convert_element(child, prev: prev_child, next_el: next_child)
111
+ end
112
+ end
113
+ end
114
+
115
+ class CLI
116
+ include Commander::Methods
117
+
118
+ def self.run!
119
+ new.run!
120
+ end
121
+
122
+ def run!
123
+ program :name, 'Booksmith'
124
+ program :version, Booksmith::VERSION
125
+ program :description, 'A flexible book publishing tool'
126
+
127
+ default_command :help
128
+
129
+ command :init do |c|
130
+ c.syntax = 'booksmith init'
131
+ c.description = 'Initialize a new book project'
132
+ c.action do |_args, _options|
133
+ CLI.create_project_structure
134
+ end
135
+ end
136
+
137
+ command :build do |c|
138
+ c.syntax = 'booksmith build [options]'
139
+ c.description = 'Generate the book in PDF and/or EPUB format'
140
+ c.option '--no-cover', 'Skip cover page generation'
141
+ c.option '--epub', 'Generate EPUB format'
142
+ c.option '--pdf', 'Generate PDF format'
143
+ c.option '--format FORMAT', String, 'Specify format to build (pdf, epub, or all)'
144
+ c.action do |_args, options|
145
+ formats = CLI.determine_formats(options)
146
+ BookGenerator.new(options).generate if formats.include?(:pdf)
147
+ EpubGenerator.new(options).generate if formats.include?(:epub)
148
+ end
149
+ end
150
+
151
+ command :fonts do |c|
152
+ c.syntax = 'booksmith fonts'
153
+ c.description = 'List registered Prawn font families'
154
+ c.action do |_args, _options|
155
+ CLI.list_fonts
156
+ end
157
+ end
158
+
159
+ super
160
+ end
161
+
162
+ def self.create_project_structure
163
+ dirs = %w[assets chapters config]
164
+ dirs.each { |dir| FileUtils.mkdir_p(dir) }
165
+
166
+ templates_dir = File.expand_path('templates', __dir__)
167
+
168
+ config_template = File.join(templates_dir, 'book.yml')
169
+ chapter_template = File.join(templates_dir, '01_introduction.md')
170
+ clock_template = File.join(templates_dir, 'clock.png')
171
+
172
+ FileUtils.cp(config_template, 'config/book.yml') if File.exist?(config_template)
173
+ FileUtils.cp(chapter_template, 'chapters/01_introduction.md') if File.exist?(chapter_template)
174
+ FileUtils.cp(clock_template, 'assets/clock.png') if File.exist?(clock_template)
175
+
176
+ puts '✅ Book project initialized!'
177
+ puts '📁 Created assets/, chapters/, and config/ directories'
178
+ puts '📝 Generated config/book.yml'
179
+ puts '📚 Generated sample chapter chapters/01_introduction.md'
180
+ puts '🖼️ Added default exercise icon assets/clock.png'
181
+ puts ''
182
+ puts 'Next steps:'
183
+ puts ' booksmith build # generates book.pdf'
184
+ puts ' booksmith build --epub # generates book.epub'
185
+ puts ' booksmith build --format all'
186
+ end
187
+
188
+ def self.determine_formats(options)
189
+ if options.format
190
+ case options.format.to_s.downcase
191
+ when 'all', 'both'
192
+ [:pdf, :epub]
193
+ when 'epub'
194
+ [:epub]
195
+ when 'pdf'
196
+ [:pdf]
197
+ else
198
+ raise ArgumentError, "Unknown format: #{options.format}. Supported formats are: pdf, epub, all"
199
+ end
200
+ elsif options.epub && options.pdf
201
+ [:pdf, :epub]
202
+ elsif options.epub
203
+ [:epub]
204
+ elsif options.pdf
205
+ [:pdf]
206
+ else
207
+ [:pdf]
208
+ end
209
+ end
210
+
211
+ def self.list_fonts
212
+ pdf = Prawn::Document.new
213
+ FontPaths.register(pdf)
214
+ puts 'Registered Prawn font families:'
215
+ pdf.font_families.keys.sort.each { |name| puts "- #{name}" }
216
+ end
217
+ end
218
+ end
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,58 @@
1
+ # CHAPTER I
2
+ <!-- subtitle: GETTING STARTED WITH BOOKSMITH -->
3
+
4
+ Welcome to your new book! This is a sample chapter generated by **Booksmith**.
5
+
6
+ Booksmith converts Markdown chapters into beautifully formatted PDF and EPUB publications using Prawn, GEPUB, and Kramdown.
7
+
8
+ ## Features Overview
9
+
10
+ Booksmith provides a rich set of layout and typography enhancements:
11
+
12
+ - **Configurable typography**: Fonts, sizes, and leading customizable via `config/book.yml`
13
+ - **Subtitle comments**: Add subtitles right under chapter titles without breaking Markdown linters
14
+ - **Specialized callouts**: Standard, alternate, and exercise blockquotes
15
+ - **Smart page breaks**: Invisible page break markers for precise print layout
16
+
17
+ <!-- PAGE_BREAK -->
18
+
19
+ ## Markdown Examples
20
+
21
+ ### Lists and Code Blocks
22
+
23
+ You can write nested bullet lists and numbered lists:
24
+
25
+ 1. First step in setting up your book:
26
+ - Edit `config/book.yml` to set your book title and author
27
+ - Place chapter Markdown files in the `chapters/` folder
28
+ 2. Run `booksmith build` to generate `book.pdf`
29
+ 3. Run `booksmith build --epub` to generate `book.epub`
30
+
31
+ Here is an example code block:
32
+
33
+ ```ruby
34
+ def publish_book
35
+ puts "Building your masterpiece with Booksmith..."
36
+ end
37
+ ```
38
+
39
+ ### Callouts and Blockquotes
40
+
41
+ Booksmith supports three distinct blockquote styles.
42
+
43
+ > **Standard Callout**
44
+ >
45
+ > When the first line is wrapped in bold text, Booksmith renders it as a distinct header row inside the shaded callout box.
46
+
47
+ > **Alternate Callout**
48
+ >
49
+ > Use `{: .alt_blockquote}` for secondary notes, sidebars, or slightly different background tinting.
50
+ {: .alt_blockquote}
51
+
52
+ > Try It Yourself
53
+ >
54
+ > Exercise callouts feature an icon (configured in `config/book.yml`) and are styled for hands-on activities:
55
+ >
56
+ > - What is the main thesis of your book?
57
+ > - Who is the intended reader?
58
+ {: .exercise_blockquote}