openstax_kitchen 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.
Files changed (121) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/devcontainer.json +19 -0
  3. data/.github/workflows/tests.yml +36 -0
  4. data/.gitignore +20 -0
  5. data/.rspec +3 -0
  6. data/.solargraph.yml +15 -0
  7. data/CHANGELOG.md +11 -0
  8. data/CODE_OF_CONDUCT.md +74 -0
  9. data/Dockerfile +19 -0
  10. data/Gemfile +9 -0
  11. data/Gemfile.lock +74 -0
  12. data/LICENSE.txt +21 -0
  13. data/README.md +674 -0
  14. data/Rakefile +6 -0
  15. data/bin/console +14 -0
  16. data/bin/normalize +79 -0
  17. data/bin/setup +8 -0
  18. data/books/chemistry2e/bake.rb +133 -0
  19. data/codecov.yaml +27 -0
  20. data/docker-compose.yml +12 -0
  21. data/docker/bash +1 -0
  22. data/docker/entrypoint +9 -0
  23. data/lib/kitchen.rb +57 -0
  24. data/lib/kitchen/ancestor.rb +30 -0
  25. data/lib/kitchen/book_document.rb +18 -0
  26. data/lib/kitchen/book_element.rb +24 -0
  27. data/lib/kitchen/book_element_enumerator.rb +5 -0
  28. data/lib/kitchen/book_recipe.rb +25 -0
  29. data/lib/kitchen/chapter_element.rb +35 -0
  30. data/lib/kitchen/chapter_element_enumerator.rb +13 -0
  31. data/lib/kitchen/clipboard.rb +37 -0
  32. data/lib/kitchen/composite_chapter_element.rb +23 -0
  33. data/lib/kitchen/composite_page_element.rb +27 -0
  34. data/lib/kitchen/composite_page_element_enumerator.rb +13 -0
  35. data/lib/kitchen/config.rb +20 -0
  36. data/lib/kitchen/counter.rb +34 -0
  37. data/lib/kitchen/debug/print_recipe_error.rb +80 -0
  38. data/lib/kitchen/directions/bake_appendix.rb +26 -0
  39. data/lib/kitchen/directions/bake_chapter_glossary.rb +34 -0
  40. data/lib/kitchen/directions/bake_chapter_introductions.rb +58 -0
  41. data/lib/kitchen/directions/bake_chapter_key_equations.rb +30 -0
  42. data/lib/kitchen/directions/bake_chapter_summary.rb +52 -0
  43. data/lib/kitchen/directions/bake_composite_pages.rb +13 -0
  44. data/lib/kitchen/directions/bake_example.rb +31 -0
  45. data/lib/kitchen/directions/bake_exercises.rb +164 -0
  46. data/lib/kitchen/directions/bake_figure.rb +25 -0
  47. data/lib/kitchen/directions/bake_footnotes/main.rb +11 -0
  48. data/lib/kitchen/directions/bake_footnotes/v1.rb +38 -0
  49. data/lib/kitchen/directions/bake_index/main.rb +11 -0
  50. data/lib/kitchen/directions/bake_index/v1.rb +138 -0
  51. data/lib/kitchen/directions/bake_index/v1.xhtml.erb +28 -0
  52. data/lib/kitchen/directions/bake_math_in_paragraph.rb +13 -0
  53. data/lib/kitchen/directions/bake_notes.rb +58 -0
  54. data/lib/kitchen/directions/bake_numbered_table/main.rb +11 -0
  55. data/lib/kitchen/directions/bake_numbered_table/v1.rb +47 -0
  56. data/lib/kitchen/directions/bake_stepwise.rb +27 -0
  57. data/lib/kitchen/directions/bake_toc.rb +103 -0
  58. data/lib/kitchen/directions/bake_unnumbered_tables.rb +14 -0
  59. data/lib/kitchen/directions/move_title_text_into_span.rb +15 -0
  60. data/lib/kitchen/document.rb +142 -0
  61. data/lib/kitchen/element.rb +15 -0
  62. data/lib/kitchen/element_base.rb +444 -0
  63. data/lib/kitchen/element_enumerator.rb +12 -0
  64. data/lib/kitchen/element_enumerator_base.rb +101 -0
  65. data/lib/kitchen/element_enumerator_factory.rb +111 -0
  66. data/lib/kitchen/element_factory.rb +32 -0
  67. data/lib/kitchen/errors.rb +4 -0
  68. data/lib/kitchen/example_element.rb +20 -0
  69. data/lib/kitchen/example_element_enumerator.rb +13 -0
  70. data/lib/kitchen/figure_element.rb +20 -0
  71. data/lib/kitchen/figure_element_enumerator.rb +13 -0
  72. data/lib/kitchen/mixins/block_error_if.rb +19 -0
  73. data/lib/kitchen/note_element.rb +43 -0
  74. data/lib/kitchen/note_element_enumerator.rb +13 -0
  75. data/lib/kitchen/oven.rb +61 -0
  76. data/lib/kitchen/page_element.rb +51 -0
  77. data/lib/kitchen/page_element_enumerator.rb +13 -0
  78. data/lib/kitchen/pantry.rb +35 -0
  79. data/lib/kitchen/patches/nokogiri.rb +31 -0
  80. data/lib/kitchen/patches/renderable.rb +31 -0
  81. data/lib/kitchen/patches/string.rb +5 -0
  82. data/lib/kitchen/recipe.rb +78 -0
  83. data/lib/kitchen/search_history.rb +33 -0
  84. data/lib/kitchen/selectors/base.rb +8 -0
  85. data/lib/kitchen/selectors/standard_1.rb +12 -0
  86. data/lib/kitchen/table_element.rb +36 -0
  87. data/lib/kitchen/table_element_enumerator.rb +13 -0
  88. data/lib/kitchen/term_element.rb +16 -0
  89. data/lib/kitchen/term_element_enumerator.rb +13 -0
  90. data/lib/kitchen/transliterations.rb +19 -0
  91. data/lib/kitchen/type_casting_element_enumerator.rb +23 -0
  92. data/lib/kitchen/utils.rb +19 -0
  93. data/lib/kitchen/version.rb +3 -0
  94. data/lib/locales/en.yml +21 -0
  95. data/lib/notes.md +9 -0
  96. data/openstax_kitchen.gemspec +39 -0
  97. data/tutorials/00/expected_baked.html +3 -0
  98. data/tutorials/00/raw.html +3 -0
  99. data/tutorials/00/solution_1.rb +7 -0
  100. data/tutorials/00/solution_2.rb +6 -0
  101. data/tutorials/01/expected_baked.html +66 -0
  102. data/tutorials/01/raw.html +62 -0
  103. data/tutorials/01/solution_1.rb +16 -0
  104. data/tutorials/01/solution_2.rb +24 -0
  105. data/tutorials/02/expected_baked.html +207 -0
  106. data/tutorials/02/raw.html +201 -0
  107. data/tutorials/02/solution_1.rb +29 -0
  108. data/tutorials/03/expected_baked.html +33 -0
  109. data/tutorials/03/raw.html +31 -0
  110. data/tutorials/03/solution_1.rb +16 -0
  111. data/tutorials/03/solution_2.rb +15 -0
  112. data/tutorials/04/expected_baked.html +36 -0
  113. data/tutorials/04/raw.html +36 -0
  114. data/tutorials/04/solution_1.rb +20 -0
  115. data/tutorials/04/solution_2.rb +25 -0
  116. data/tutorials/05/expected_baked.html +11 -0
  117. data/tutorials/05/raw.html +11 -0
  118. data/tutorials/05/solution_1.rb +9 -0
  119. data/tutorials/check_it +64 -0
  120. data/tutorials/setup_my_recipes +30 -0
  121. metadata +278 -0
@@ -0,0 +1,15 @@
1
+ module Kitchen
2
+ class Element < ElementBase
3
+
4
+ def initialize(node:, document:, short_type: nil)
5
+ super(node: node,
6
+ document: document,
7
+ enumerator_class: ElementEnumerator,
8
+ short_type: short_type)
9
+ end
10
+
11
+ # # @!method pages
12
+ # # Returns a pages enumerator
13
+ # def_delegators :as_enumerator, :pages, :chapters, :terms, :figures, :notes, :tables, :examples
14
+ end
15
+ end
@@ -0,0 +1,444 @@
1
+ require 'forwardable'
2
+ require 'securerandom'
3
+
4
+ module Kitchen
5
+
6
+ # Abstract base class for all elements. If you are looking for a simple concrete
7
+ # element class, use `Element`.
8
+ #
9
+ class ElementBase
10
+ extend Forwardable
11
+ include Mixins::BlockErrorIf
12
+
13
+ attr_reader :document
14
+ attr_reader :short_type
15
+ attr_reader :enumerator_class
16
+ attr_accessor :css_or_xpath_that_found_me
17
+
18
+ # @!method name
19
+ # Get the element name (the tag)
20
+ # @!method name=
21
+ # Set the element name (the tag)
22
+ # @!method []
23
+ # Get an element attribute
24
+ # @!method []=
25
+ # Set an element attribute
26
+ # @!method add_class
27
+ # Add a class to the element
28
+ # @!method remove_class
29
+ # Remove a class from the element
30
+ def_delegators :@node, :name=, :name, :[], :[]=, :add_class, :remove_class,
31
+ :text, :wrap, :children, :to_html, :remove_attribute,
32
+ :classes, :path
33
+
34
+ def_delegators :document, :config
35
+ def_delegators :config, :selectors
36
+
37
+ def initialize(node:, document:, enumerator_class:, short_type: nil)
38
+ raise(ArgumentError, "node cannot be nil") if node.nil?
39
+ @node = node
40
+
41
+ raise(ArgumentError, "enumerator_class cannot be nil") if enumerator_class.nil?
42
+ @enumerator_class = enumerator_class
43
+
44
+ @short_type = short_type || "unknown_type_#{SecureRandom.hex(4)}"
45
+
46
+ @document =
47
+ case document
48
+ when Kitchen::Document
49
+ document
50
+ else
51
+ raise(ArgumentError, "`document` is not a known document type")
52
+ end
53
+
54
+ @ancestors = HashWithIndifferentAccess.new
55
+ @counts_in = HashWithIndifferentAccess.new
56
+ @css_or_xpath_that_has_been_counted = {}
57
+ @is_a_clone = false
58
+ end
59
+
60
+ def self.is_the_element_class_for?(node)
61
+ # override this in subclasses
62
+ false
63
+ end
64
+
65
+ def has_class?(klass)
66
+ (self[:class] || "").include?(klass)
67
+ end
68
+
69
+ def id
70
+ self[:id]
71
+ end
72
+
73
+ def id=(value)
74
+ self[:id] = value
75
+ end
76
+
77
+ # A way to set values and chain them
78
+ def set(property, value)
79
+ case property.to_sym
80
+ when :name
81
+ self.name = value
82
+ else
83
+ self[property.to_sym] = value
84
+ end
85
+ self
86
+ end
87
+
88
+ def ancestor(type)
89
+ @ancestors[type.to_sym]&.element || raise("No ancestor of type '#{type}'")
90
+ end
91
+
92
+ def has_ancestor?(type)
93
+ @ancestors[type.to_sym].present?
94
+ end
95
+
96
+ def ancestors
97
+ @ancestors
98
+ end
99
+
100
+ def add_ancestors(*args)
101
+ args.each do |arg|
102
+ case arg
103
+ when Hash
104
+ add_ancestors(*arg.values)
105
+ when Ancestor
106
+ add_ancestor(arg)
107
+ when Element, Document
108
+ add_ancestor(Ancestor.new(arg))
109
+ else
110
+ raise "Unsupported ancestor type `#{arg.class}`"
111
+ end
112
+ end
113
+ end
114
+
115
+ def add_ancestor(ancestor)
116
+ if @ancestors[ancestor.type].present?
117
+ raise "Trying to add an ancestor of type '#{ancestor.type}' but one of that " \
118
+ "type is already present"
119
+ end
120
+
121
+ @ancestors[ancestor.type] = ancestor
122
+ end
123
+
124
+ def ancestor_elements
125
+ @ancestors.values.map(&:element)
126
+ end
127
+
128
+ def count_as_descendant
129
+ @ancestors.each_pair do |type, ancestor|
130
+ @counts_in[type] = ancestor.increment_descendant_count(short_type)
131
+ end
132
+ end
133
+
134
+ def count_in(ancestor_type)
135
+ @counts_in[ancestor_type] || raise("No ancestor of type '#{ancestor_type}'")
136
+ end
137
+
138
+ def remember_that_sub_elements_are_already_counted(css_or_xpath:, count:)
139
+ @css_or_xpath_that_has_been_counted[css_or_xpath] = count
140
+ end
141
+
142
+ def have_sub_elements_already_been_counted?(css_or_xpath)
143
+ number_of_sub_elements_already_counted(css_or_xpath) != 0
144
+ end
145
+
146
+ def number_of_sub_elements_already_counted(css_or_xpath)
147
+ @css_or_xpath_that_has_been_counted[css_or_xpath] || 0
148
+ end
149
+
150
+ def search_history
151
+ history = ancestor_elements.map(&:css_or_xpath_that_found_me) + [css_or_xpath_that_found_me]
152
+ history.compact.join(" ")
153
+ end
154
+
155
+ def search(*selector_or_xpath_args)
156
+ block_error_if(block_given?)
157
+
158
+ ElementEnumerator.factory.build_within(self, css_or_xpath: selector_or_xpath_args)
159
+ end
160
+
161
+ # Yields and returns the first child element that matches the provided
162
+ # selector or XPath arguments.
163
+ #
164
+ # @param selector_or_xpath_args [Array<String>] CSS selectors or XPath arguments
165
+ # @yieldparam [Element] the matched XML element
166
+ # @return [Element, nil] the matched XML element or nil if no match found
167
+ #
168
+ def first(*selector_or_xpath_args)
169
+ search(*selector_or_xpath_args).first.tap do |element|
170
+ yield(element) if block_given?
171
+ end
172
+ end
173
+
174
+ # Yields and returns the first child element that matches the provided
175
+ # selector or XPath arguments.
176
+ #
177
+ # @param selector_or_xpath_args [Array<String>] CSS selectors or XPath arguments
178
+ # @yieldparam [Element] the matched XML element
179
+ # @raise [ElementNotFoundError] if no matching element is found
180
+ # @return [Element] the matched XML element
181
+ #
182
+ def first!(*selector_or_xpath_args)
183
+ search(*selector_or_xpath_args).first!.tap do |element|
184
+ yield(element) if block_given?
185
+ end
186
+ end
187
+
188
+ alias_method :at, :first
189
+
190
+ def element_children()
191
+ block_error_if(block_given?)
192
+ TypeCastingElementEnumerator.factory.build_within(self, css_or_xpath: "./*")
193
+ end
194
+
195
+ def search_with(*enumerator_classes)
196
+ block_error_if(block_given?)
197
+ raise "must supply at least one enumerator class" if enumerator_classes.empty?
198
+
199
+ factory = enumerator_classes[0].factory
200
+ enumerator_classes[1..-1].each do |enumerator_class|
201
+ factory = factory.or_with(enumerator_class.factory)
202
+ end
203
+ factory.build_within(self)
204
+ end
205
+
206
+ # Removes the element from its parent and places it on the specified clipboard
207
+ #
208
+ # @param to [Symbol, String, Clipboard, nil] the name of the clipboard (or a Clipboard
209
+ # object) to cut to. String values are converted to symbols. If not provided, the
210
+ # element is not placed on a clipboard.
211
+ # @return [Element] the cut element
212
+ #
213
+ def cut(to: nil)
214
+ block_error_if(block_given?)
215
+
216
+ node.remove
217
+ clipboard(to).add(self) if to.present?
218
+ self
219
+ end
220
+
221
+ # Makes a copy of the element and places it on the specified clipboard.
222
+ #
223
+ # @param to [Symbol, String, Clipboard, nil] the name of the clipboard (or a Clipboard
224
+ # object) to cut to. String values are converted to symbols. If not provided, the
225
+ # copy is not placed on a clipboard.
226
+ # @return [Element] the copied element
227
+ #
228
+ def copy(to: nil)
229
+ # See `clone` method for a note about namespaces
230
+ block_error_if(block_given?)
231
+
232
+ the_copy = clone
233
+ the_copy.raw.traverse do |node|
234
+ next if node.text? || node.document?
235
+ document.record_id_copied(node[:id])
236
+ end
237
+ clipboard(to).add(the_copy) if to.present?
238
+ the_copy
239
+ end
240
+
241
+ # When an element is cut or copied, use this method to get the element's content;
242
+ # keeps IDs unique
243
+ def paste
244
+ # See `clone` method for a note about namespaces
245
+ block_error_if(block_given?)
246
+
247
+ temp_copy = clone
248
+ temp_copy.raw.traverse do |node|
249
+ next if node.text? || node.document?
250
+ node[:id] = document.modified_id_to_paste(node[:id]) unless node[:id].blank?
251
+ end
252
+ temp_copy.to_s
253
+ end
254
+
255
+ # Delete the element
256
+ #
257
+ def trash
258
+ node.remove
259
+ self
260
+ end
261
+
262
+ def parent
263
+ Element.new(node: raw.parent, document: document, short_type: "parent(#{short_type})")
264
+ end
265
+
266
+ # TODO make it clear if all of these methods take Element, Node, or String
267
+
268
+ # If child argument given, prepends it before the element's current children.
269
+ # If sibling is given, prepends it as a sibling to this element.
270
+ #
271
+ # @param child [String] the child to prepend
272
+ # @param sibling [String] the sibling to prepend
273
+ #
274
+ def prepend(child: nil, sibling: nil)
275
+ if child && sibling
276
+ raise RecipeError, "Only one of `child` or `sibling` can be specified"
277
+ elsif !child && !sibling
278
+ raise RecipeError, "One of `child` or `sibling` must be specified"
279
+ elsif child
280
+ if node.children.empty?
281
+ node.children = child.to_s
282
+ else
283
+ node.children.first.add_previous_sibling(child)
284
+ end
285
+ else
286
+ node.add_previous_sibling(sibling)
287
+ end
288
+ self
289
+ end
290
+
291
+ # If child argument given, appends it after the element's current children.
292
+ # If sibling is given, appends it as a sibling to this element.
293
+ #
294
+ # @param child [String] the child to append
295
+ # @param sibling [String] the sibling to append
296
+ #
297
+ def append(child: nil, sibling: nil)
298
+ if child && sibling
299
+ raise RecipeError, "Only one of `child` or `sibling` can be specified"
300
+ elsif !child && !sibling
301
+ raise RecipeError, "One of `child` or `sibling` must be specified"
302
+ elsif child
303
+ if node.children.empty?
304
+ node.children = child.to_s
305
+ else
306
+ node.add_child(child)
307
+ end
308
+ else
309
+ node.next = sibling
310
+ end
311
+ self
312
+ end
313
+
314
+ # Replaces this element's children
315
+ #
316
+ # @param with [String] the children to substitute for the current children
317
+ #
318
+ def replace_children(with:)
319
+ node.children = with
320
+ self
321
+ end
322
+
323
+ # TODO methods like replace_children that take string, either forbid or handle Element/Node args
324
+
325
+ # Get the content of children matching the provided selector. Mostly
326
+ # useful when there is one child with text you want to extract.
327
+ #
328
+ # @param selector_or_xpath_args [Array<String>] CSS selectors or XPath arguments
329
+ # @return [String]
330
+ #
331
+ def content(*selector_or_xpath_args)
332
+ node.search(*selector_or_xpath_args).children.to_s
333
+ end
334
+
335
+ # Returns true if this element has a child matching the provided selector
336
+ #
337
+ # @param selector_or_xpath_args [Array<String>] CSS selectors or XPath arguments
338
+ # @return [Boolean]
339
+ #
340
+ def contains?(*selector_or_xpath_args)
341
+ !node.at(*selector_or_xpath_args).nil?
342
+ end
343
+
344
+ # Returns the header tag name that is one level under the first header tag in this
345
+ # element, e.g. if this element is a "div" whose first header is "h1", this will
346
+ # return "h2"
347
+ #
348
+ # TODO this method may not be needed.
349
+ #
350
+ # @return [String] the sub header tag name
351
+ #
352
+ def sub_header_name
353
+ first_header = node.search("h1, h2, h3, h4, h5, h6").first
354
+
355
+ first_header.nil? ?
356
+ "h1" :
357
+ first_header.name.gsub(/\d/) {|num| (num.to_i + 1).to_s}
358
+ end
359
+
360
+ # Returns the underlying Nokogiri object
361
+ #
362
+ # @return [Nokogiri::XML::Node]
363
+ #
364
+ def raw
365
+ node
366
+ end
367
+
368
+ def inspect
369
+ to_s
370
+ end
371
+
372
+ def to_s
373
+ remove_default_namespaces_if_clone(node.to_s)
374
+ end
375
+
376
+ def to_xml
377
+ remove_default_namespaces_if_clone(node.to_xml)
378
+ end
379
+
380
+ def to_xhtml
381
+ remove_default_namespaces_if_clone(node.to_xhtml)
382
+ end
383
+
384
+ def clone
385
+ super.tap do |element|
386
+ # When we call dup, the dup gets a bunch of default namespace stuff that
387
+ # the original doesn't have. Why? Unclear, but hard to get rid of nicely.
388
+ # So here we mark that the element is a clone and then all of the `to_s`-like
389
+ # methods gsub out the default namespace gunk. Clones are mostly used for
390
+ # clipboards and are accessed using `paste` methods, so modifying the `to_s`
391
+ # behavior works for us. If we end up using `clone` in a way that doesn't
392
+ # eventually get converted to string, we may have to investigate other
393
+ # options.
394
+ #
395
+ # An alternative is to remove the `xmlns` attribute in the `html` tag before
396
+ # the input file is parse into a Nokogiri document and then to add it back
397
+ # in when the baked file is written out.
398
+ #
399
+ # Nokogiri::XML::Document.remove_namespaces! is not an option because that blows
400
+ # away our MathML namespace.
401
+ #
402
+ # I may not fully understand why the extra default namespace stuff is happening
403
+ # FWIW :-)
404
+ #
405
+ element.node = node.dup
406
+ element.is_a_clone = true
407
+ end
408
+ end
409
+
410
+ # @!method pages
411
+ # Returns a pages enumerator
412
+ def_delegators :as_enumerator, :pages, :chapters, :terms, :figures, :notes, :tables, :examples
413
+
414
+ def as_enumerator
415
+ enumerator_class.new(css_or_xpath: css_or_xpath_that_found_me) {|block| block.yield(self)}
416
+ end
417
+
418
+ protected
419
+
420
+ attr_accessor :node
421
+ attr_accessor :is_a_clone
422
+
423
+ def clipboard(name_or_object)
424
+ case name_or_object
425
+ when Symbol
426
+ document.clipboard(name: name_or_object)
427
+ when Clipboard
428
+ name_or_object
429
+ else
430
+ raise ArgumentError, "The provided argument (#{name_or_object}) is not "
431
+ "a clipboard name or a clipboard"
432
+ end
433
+ end
434
+
435
+ def remove_default_namespaces_if_clone(string)
436
+ if is_a_clone
437
+ string.gsub("xmlns:default=\"http://www.w3.org/1999/xhtml\"","").gsub("default:","")
438
+ else
439
+ string
440
+ end
441
+ end
442
+
443
+ end
444
+ end