openstax_kitchen 4.0.0 → 6.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/changelog.yml +24 -0
  3. data/.github/workflows/rubocop.yml +28 -0
  4. data/CHANGELOG.md +58 -0
  5. data/Gemfile.lock +15 -6
  6. data/README.md +16 -0
  7. data/codecov.yaml +1 -0
  8. data/docker/rubocop +22 -0
  9. data/lib/kitchen/book_document.rb +1 -1
  10. data/lib/kitchen/chapter_element.rb +2 -2
  11. data/lib/kitchen/composite_chapter_element_enumerator.rb +21 -0
  12. data/lib/kitchen/composite_page_element.rb +19 -2
  13. data/lib/kitchen/config.rb +7 -0
  14. data/lib/kitchen/directions/bake_appendix.rb +3 -1
  15. data/lib/kitchen/directions/bake_chapter_introductions.rb +22 -15
  16. data/lib/kitchen/directions/bake_chapter_introductions/chapter_introduction.xhtml.erb +0 -0
  17. data/lib/kitchen/directions/bake_chapter_key_concepts/v1.rb +1 -1
  18. data/lib/kitchen/directions/bake_chapter_references/main.rb +15 -0
  19. data/lib/kitchen/directions/bake_chapter_references/v1.rb +49 -0
  20. data/lib/kitchen/directions/bake_chapter_section_exercises/main.rb +2 -2
  21. data/lib/kitchen/directions/bake_chapter_section_exercises/v1.rb +2 -1
  22. data/lib/kitchen/directions/bake_chapter_solutions/main.rb +11 -0
  23. data/lib/kitchen/directions/bake_chapter_solutions/v1.rb +37 -0
  24. data/lib/kitchen/directions/bake_chapter_summary.rb +13 -6
  25. data/lib/kitchen/directions/bake_composite_chapters.rb +1 -1
  26. data/lib/kitchen/directions/bake_composite_pages.rb +1 -1
  27. data/lib/kitchen/directions/bake_equations.rb +1 -1
  28. data/lib/kitchen/directions/bake_example.rb +4 -1
  29. data/lib/kitchen/directions/bake_figure.rb +13 -0
  30. data/lib/kitchen/directions/bake_first_elements.rb +7 -1
  31. data/lib/kitchen/directions/bake_footnotes/main.rb +2 -2
  32. data/lib/kitchen/directions/bake_footnotes/v1.rb +11 -8
  33. data/lib/kitchen/directions/bake_further_research.rb +2 -0
  34. data/lib/kitchen/directions/bake_index/v1.rb +3 -14
  35. data/lib/kitchen/directions/bake_inline_lists.rb +22 -0
  36. data/lib/kitchen/directions/bake_notes/bake_numbered_notes/main.rb +43 -0
  37. data/lib/kitchen/directions/bake_notes/bake_numbered_notes/v1.rb +37 -0
  38. data/lib/kitchen/directions/bake_notes/bake_numbered_notes/v2.rb +25 -0
  39. data/lib/kitchen/directions/bake_notes/bake_numbered_notes/v3.rb +32 -0
  40. data/lib/kitchen/directions/bake_numbered_exercise/main.rb +3 -2
  41. data/lib/kitchen/directions/bake_numbered_exercise/v1.rb +10 -1
  42. data/lib/kitchen/directions/bake_numbered_table/bake_table_body.rb +29 -0
  43. data/lib/kitchen/directions/bake_numbered_table/main.rb +4 -0
  44. data/lib/kitchen/directions/bake_numbered_table/v1.rb +1 -24
  45. data/lib/kitchen/directions/bake_numbered_table/v2.rb +31 -0
  46. data/lib/kitchen/directions/bake_preface/main.rb +2 -2
  47. data/lib/kitchen/directions/bake_preface/v1.rb +3 -2
  48. data/lib/kitchen/directions/bake_references/main.rb +7 -0
  49. data/lib/kitchen/directions/bake_references/v2.rb +35 -0
  50. data/lib/kitchen/directions/bake_toc.rb +3 -1
  51. data/lib/kitchen/directions/book_answer_key_container/eob_answer_key_outer_container.xhtml.erb +9 -0
  52. data/lib/kitchen/directions/book_answer_key_container/main.rb +2 -2
  53. data/lib/kitchen/directions/book_answer_key_container/v1.rb +4 -3
  54. data/lib/kitchen/directions/chapter_review_container/chapter_review.xhtml.erb +3 -3
  55. data/lib/kitchen/directions/chapter_review_container/main.rb +2 -2
  56. data/lib/kitchen/directions/chapter_review_container/v1.rb +4 -2
  57. data/lib/kitchen/directions/eoc_section_title_link_snippet.rb +13 -0
  58. data/lib/kitchen/directions/move_exercises_to_eoc/main.rb +10 -0
  59. data/lib/kitchen/directions/move_exercises_to_eoc/v3.rb +49 -0
  60. data/lib/kitchen/directions/move_solutions_to_answer_key/main.rb +6 -2
  61. data/lib/kitchen/directions/move_solutions_to_answer_key/strategies/default.rb +27 -0
  62. data/lib/kitchen/directions/move_solutions_to_answer_key/strategies/precalculus.rb +84 -0
  63. data/lib/kitchen/directions/move_solutions_to_answer_key/strategies/uphysics.rb +2 -2
  64. data/lib/kitchen/directions/move_solutions_to_answer_key/v1.rb +11 -7
  65. data/lib/kitchen/document.rb +19 -52
  66. data/lib/kitchen/element_base.rb +48 -12
  67. data/lib/kitchen/element_enumerator_base.rb +24 -1
  68. data/lib/kitchen/element_enumerator_factory.rb +19 -7
  69. data/lib/kitchen/exercise_element.rb +2 -2
  70. data/lib/kitchen/id_tracker.rb +68 -0
  71. data/lib/kitchen/oven.rb +5 -1
  72. data/lib/kitchen/page_element.rb +7 -5
  73. data/lib/kitchen/patches/i18n.rb +34 -0
  74. data/lib/kitchen/patches/integer.rb +24 -0
  75. data/lib/kitchen/patches/nokogiri.rb +62 -0
  76. data/lib/kitchen/patches/nokogiri_profiling.rb +60 -0
  77. data/lib/kitchen/search_query.rb +6 -0
  78. data/lib/kitchen/selector.rb +3 -2
  79. data/lib/kitchen/version.rb +1 -1
  80. data/lib/locales/en.yml +2 -1
  81. data/lib/locales/es.yml +33 -0
  82. data/lib/locales/pl.yml +4 -2
  83. data/lib/openstax_kitchen.rb +1 -5
  84. data/openstax_kitchen.gemspec +1 -0
  85. metadata +42 -7
  86. data/.github/config.yml +0 -14
  87. data/lib/kitchen/directions/bake_notes/bake_numbered_notes.rb +0 -51
  88. data/lib/kitchen/directions/book_answer_key_container/eob_solutions_container.xhtml.erb +0 -9
  89. data/lib/kitchen/directions/move_solutions_to_answer_key/strategies/american_government.rb +0 -19
  90. data/lib/kitchen/transliterations.rb +0 -21
@@ -84,7 +84,30 @@ module Kitchen
84
84
  #
85
85
  def composite_pages(css_or_xpath=nil, only: nil, except: nil)
86
86
  block_error_if(block_given?)
87
- chain_to(CompositePageElementEnumerator, css_or_xpath: css_or_xpath, only: only, except: except)
87
+ chain_to(CompositePageElementEnumerator,
88
+ css_or_xpath: css_or_xpath,
89
+ only: only,
90
+ except: except)
91
+ end
92
+
93
+ # Returns an enumerator that iterates through composite chapters within the scope of this enumerator
94
+ #
95
+ # @param css_or_xpath [String] additional selectors to further narrow the element iterated over;
96
+ # a "$" in this argument will be replaced with the default selector for the element being
97
+ # iterated over.
98
+ # @param only [Symbol, Callable] the name of a method to call on an element or a
99
+ # lambda or proc that accepts an element; elements will only be included in the
100
+ # search results if the method or callable returns true
101
+ # @param except [Symbol, Callable] the name of a method to call on an element or a
102
+ # lambda or proc that accepts an element; elements will not be included in the
103
+ # search results if the method or callable returns false
104
+ #
105
+ def composite_chapters(css_or_xpath=nil, only: nil, except: nil)
106
+ block_error_if(block_given?)
107
+ chain_to(CompositeChapterElementEnumerator,
108
+ css_or_xpath: css_or_xpath,
109
+ only: only,
110
+ except: except)
88
111
  end
89
112
 
90
113
  # Returns an enumerator that iterates through pages that arent the introduction page within the scope of this enumerator
@@ -39,12 +39,22 @@ module Kitchen
39
39
  # @return [ElementEnumeratorBase] actually returns the concrete enumerator class
40
40
  # given to the factory in its constructor.
41
41
  #
42
- def build_within(enumerator_or_element, search_query: SearchQuery.new)
42
+ def build_within(enumerator_or_element, search_query: SearchQuery.new, reload: false)
43
43
  case enumerator_or_element
44
44
  when ElementBase
45
- build_within_element(enumerator_or_element, search_query: search_query)
45
+ build_within_element(enumerator_or_element,
46
+ search_query: search_query,
47
+ reload: reload)
46
48
  when ElementEnumeratorBase
47
- build_within_other_enumerator(enumerator_or_element, search_query: search_query)
49
+ if enumerator_class != ElementEnumerator && !search_query.expects_substitution?
50
+ raise "Query #{search_query} is missing the substitution character ('$') but " \
51
+ "is run with an enumerator #{enumerator_class.name} that has its own " \
52
+ "selectors for substitution."
53
+ end
54
+
55
+ build_within_other_enumerator(enumerator_or_element,
56
+ search_query: search_query,
57
+ reload: reload)
48
58
  end
49
59
  end
50
60
 
@@ -64,7 +74,7 @@ module Kitchen
64
74
 
65
75
  protected
66
76
 
67
- def build_within_element(element, search_query:)
77
+ def build_within_element(element, search_query:, reload:)
68
78
  search_query.apply_default_css_or_xpath_and_normalize(default_css_or_xpath,
69
79
  config: element.config)
70
80
 
@@ -77,7 +87,7 @@ module Kitchen
77
87
  # below, the counts are correct.
78
88
  element.uncount(search_query)
79
89
 
80
- element.raw.search(*search_query.css_or_xpath).each do |sub_node|
90
+ element.raw_search(*search_query.css_or_xpath, reload: reload).each do |sub_node|
81
91
  sub_element = ElementFactory.build_from_node(
82
92
  node: sub_node,
83
93
  document: element.document,
@@ -105,13 +115,15 @@ module Kitchen
105
115
  end
106
116
  end
107
117
 
108
- def build_within_other_enumerator(other_enumerator, search_query:)
118
+ def build_within_other_enumerator(other_enumerator, search_query:, reload:)
109
119
  # Return a new enumerator instance that internally iterates over `other_enumerator`
110
120
  # running a new enumerator for each element returned by that other enumerator.
111
121
  enumerator_class.new(search_query: search_query,
112
122
  upstream_enumerator: other_enumerator) do |block|
113
123
  other_enumerator.each do |element|
114
- build_within_element(element, search_query: search_query).each do |sub_element|
124
+ build_within_element(element,
125
+ search_query: search_query,
126
+ reload: reload).each do |sub_element|
115
127
  block.yield(sub_element)
116
128
  end
117
129
  end
@@ -28,7 +28,7 @@ module Kitchen
28
28
  # @return ElementEnumerator
29
29
  #
30
30
  def problem
31
- first("[data-type='problem']")
31
+ first("div[data-type='problem']")
32
32
  end
33
33
 
34
34
  # Returns the enumerator for solution.
@@ -36,7 +36,7 @@ module Kitchen
36
36
  # @return ElementEnumerator
37
37
  #
38
38
  def solution
39
- first("[data-type='solution']")
39
+ first("div[data-type='solution']")
40
40
  end
41
41
  end
42
42
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitchen
4
+ # A class to track and modify duplicate IDs in a document
5
+ #
6
+ class IdTracker
7
+
8
+ def initialize
9
+ @id_data = Hash.new { |hash, key| hash[key] = { count: 0, last_pasted: false } }
10
+ @id_copy_suffix = '_copy_'
11
+ end
12
+
13
+ # Keeps track that an element with the given ID has been copied. When such
14
+ # elements are pasted, this information is used to give those elements unique
15
+ # IDs that don't duplicate the original element.
16
+ #
17
+ # @param id [String] the ID
18
+ #
19
+ def record_id_copied(id)
20
+ return if id.blank?
21
+
22
+ @id_data[id][:count] += 1
23
+ @id_data[id][:last_pasted] = false
24
+ end
25
+
26
+ # Keeps track that an element with the given ID has been cut.
27
+ #
28
+ # @param id [String]
29
+ #
30
+ def record_id_cut(id)
31
+ return if id.blank?
32
+
33
+ @id_data[id][:count] -= 1 if @id_data[id][:count].positive?
34
+ @id_data[id][:last_pasted] = false
35
+ end
36
+
37
+ # Keeps track that an element with the given ID has been pasted.
38
+ #
39
+ # @param id [String]
40
+ #
41
+ def record_id_pasted(id)
42
+ return if id.blank?
43
+
44
+ @id_data[id][:count] += 1 if @id_data[id][:last_pasted]
45
+ @id_data[id][:last_pasted] = true
46
+ end
47
+
48
+ # Returns a unique ID given the ID of an element that was copied and is about
49
+ # to be pasted
50
+ #
51
+ # @param original_id [String]
52
+ # @return [String]
53
+ #
54
+ def modified_id_to_paste(original_id)
55
+ return nil if original_id.nil?
56
+ return '' if original_id.blank?
57
+
58
+ count = @id_data[original_id][:count]
59
+ # A count of 0 means the element was cut and this is the first paste, do not
60
+ # modify the ID; otherwise, use the uniquified ID.
61
+ if count.zero?
62
+ original_id
63
+ else
64
+ "#{original_id}#{@id_copy_suffix}#{count}"
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/kitchen/oven.rb CHANGED
@@ -28,6 +28,8 @@ module Kitchen
28
28
  config: config
29
29
  )
30
30
 
31
+ I18n.locale = doc.locale
32
+
31
33
  [recipes].flatten.each do |recipe|
32
34
  recipe.document = doc
33
35
  recipe.bake
@@ -35,10 +37,12 @@ module Kitchen
35
37
  profile.baked!
36
38
 
37
39
  File.open(output_file, 'w') do |f|
38
- f.write doc.to_xhtml(indent: 2)
40
+ f.write doc.to_xhtml(indent: 2, encoding: doc.encoding || 'utf-8')
39
41
  end
40
42
  profile.written!
41
43
 
44
+ Nokogiri::XML.print_profile_data if ENV['PROFILE'] && !ENV['TESTING']
45
+
42
46
  profile
43
47
  end
44
48
 
@@ -29,9 +29,12 @@ module Kitchen
29
29
  # @raise [ElementNotFoundError] if no matching element is found
30
30
  # @return [Element]
31
31
  #
32
- def title
32
+ def title(reload: false)
33
33
  # The selector for intro titles changes during the baking process
34
- first!(is_introduction? ? selectors.title_in_introduction_page : selectors.title_in_page)
34
+ @title ||= begin
35
+ selector = is_introduction? ? selectors.title_in_introduction_page : selectors.title_in_page
36
+ first!(selector, reload: reload)
37
+ end
35
38
  end
36
39
 
37
40
  # Returns the title's text regardless of whether the title has been baked
@@ -85,11 +88,10 @@ module Kitchen
85
88
 
86
89
  # Returns the summary element.
87
90
  #
88
- # @raise [ElementNotFoundError] if no matching element is found
89
- # @return [Element]
91
+ # @return [Element, nil] the summary or nil if no summary found
90
92
  #
91
93
  def summary
92
- first!(selectors.page_summary)
94
+ first(selectors.page_summary)
93
95
  end
94
96
 
95
97
  # Returns the exercises element.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'twitter_cldr'
4
+
5
+ # rubocop:disable Style/Documentation
6
+ module I18n
7
+ def self.sort_strings(first, second)
8
+ string_sorter.compare(first, second)
9
+ end
10
+
11
+ def self.string_sorter
12
+ @string_sorter ||= begin
13
+ # TwitterCldr does not know about our :test locale, so substitute the English one
14
+ locale = I18n.locale == :test ? :en : I18n.locale
15
+ TwitterCldr::Collation::Collator.new(locale)
16
+ end
17
+ end
18
+
19
+ def self.clear_string_sorter
20
+ @string_sorter = nil
21
+ end
22
+
23
+ class <<self
24
+ alias_method :original_locale=, :locale=
25
+ end
26
+
27
+ def self.locale=(locale)
28
+ # We wrap the setting of locale so that we can clear the string sorter (so that it
29
+ # gets reset to the new locale the next time it is used)
30
+ clear_string_sorter
31
+ self.original_locale = locale
32
+ end
33
+ end
34
+ # rubocop:enable Style/Documentation
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Monkey patches for +Integer+
4
+ #
5
+ class Integer
6
+ ROMAN_NUMERALS = %w[0 i ii iii iv v vi vii viii ix x xi xii xiii xiv xv xvi xvii xviii xix xx].freeze
7
+
8
+ # Formats as different types of integers, including roman numerals.
9
+ #
10
+ # @return [String]
11
+ #
12
+ def to_format(format)
13
+ case format
14
+ when :arabic
15
+ to_s
16
+ when :roman
17
+ raise 'Unknown conversion to Roman numerals' if self >= ROMAN_NUMERALS.size
18
+
19
+ ROMAN_NUMERALS[self]
20
+ else
21
+ raise 'Unknown integer format'
22
+ end
23
+ end
24
+ end
@@ -31,6 +31,21 @@ module Nokogiri
31
31
  end
32
32
  end
33
33
  end
34
+
35
+ def add_all_namespaces!
36
+ # Nokogiri by default only recognizes the namespaces on the root node. Collect all
37
+ # namespaces and add them manually.
38
+ return if @all_namespaces_added
39
+
40
+ collect_namespaces.each do |namespace, url|
41
+ prefix, name = namespace.split(':')
42
+ next unless prefix == 'xmlns' && name.present?
43
+
44
+ root.add_namespace_definition(name, url)
45
+ end
46
+
47
+ @all_namespaces_added = true
48
+ end
34
49
  end
35
50
 
36
51
  # Monkey patches for Nokogiri::XML::Node
@@ -43,6 +58,53 @@ module Nokogiri
43
58
  def inspect
44
59
  to_s
45
60
  end
61
+
62
+ def quick_matches?(selector)
63
+ self.class.selector_to_css_nodes(selector).any? { |css_node| matches_css_node?(css_node) }
64
+ end
65
+
66
+ def classes
67
+ self[:class]&.split || []
68
+ end
69
+
70
+ def previous
71
+ prev = previous_element
72
+ return nil if prev.nil?
73
+
74
+ prev.text? ? prev.previous : prev
75
+ end
76
+
77
+ def self.selector_to_css_nodes(selector)
78
+ # No need to parse the same selector more than once.
79
+ @parsed_selectors ||= {}
80
+ @parsed_selectors[selector] ||= Nokogiri::CSS::Parser.new.parse(selector)
81
+ end
82
+
83
+ protected
84
+
85
+ # rubocop:disable Metrics/CyclomaticComplexity
86
+ def matches_css_node?(css_node)
87
+ case css_node.type
88
+ when :CONDITIONAL_SELECTOR
89
+ css_node.value.all? { |inner_css_node| matches_css_node?(inner_css_node) }
90
+ when :ELEMENT_NAME
91
+ css_node.value == ['*'] || css_node.value.include?(name)
92
+ when :CLASS_CONDITION
93
+ (css_node.value & classes).any?
94
+ when :ATTRIBUTE_CONDITION
95
+ attribute, operator, value = css_node.value
96
+
97
+ raise "Unknown attribute condition operator in #{css_node}" if operator != :equal
98
+
99
+ attribute_name = attribute.value
100
+ raise "More attribute names than expected, #{attribute_name}" if attribute_name.many?
101
+
102
+ self[attribute_name.first] == value.gsub('"', '').gsub("'", '')
103
+ else
104
+ raise "Unknown Nokogiri::CSS:Node type in #{css_node}"
105
+ end
106
+ end
107
+ # rubocop:enable Metrics/CyclomaticComplexity
46
108
  end
47
109
  end
48
110
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/Documentation
4
+
5
+ # Make debug output more useful (dumping entire document out is not useful)
6
+ module Nokogiri
7
+ module XML
8
+ # rubocop:disable Style/MutableConstant
9
+ PROFILE_DATA = {}
10
+ # rubocop:enable Style/MutableConstant
11
+
12
+ if ENV['PROFILE']
13
+ ENV['VERBOSE_PROFILE'] = 1 if ENV['PROFILE'].to_s.downcase == 'verbose'
14
+
15
+ # Patches inside Nokogiri to count, time, and print searches. At end of baking
16
+ # you can `puts Nokogiri::XML::PROFILE_DATA` to see the totals. The counts
17
+ # hash is defined outside of the if block so that code that prints it doesn't
18
+ # explode if run without the env var. The `print_profile_data` method is also
19
+ # provided for a nice printout
20
+
21
+ def self.print_profile_data
22
+ total_duration = 0
23
+
24
+ sorted_profile_data = PROFILE_DATA.sort_by { |_, data| data[:time] / data[:count] }.reverse
25
+
26
+ puts "\nSearch Profile Data"
27
+ puts '-----------------------------------------------------------------'
28
+ puts "#{'Total Time (ms)'.ljust(17)}#{'Avg Time (ms)'.ljust(15)}#{'Count'.ljust(7)}Query"
29
+ puts '-----------------------------------------------------------------'
30
+
31
+ sorted_profile_data.each do |search_path, data|
32
+ total_time = format('%0.4f', (data[:time] * 1000))
33
+ avg_time = format('%0.4f', ((data[:time] / data[:count]) * 1000))
34
+ puts total_time.ljust(17) + avg_time.to_s.ljust(15) + data[:count].to_s.ljust(7) + \
35
+ search_path
36
+ total_duration += data[:time] * 1000
37
+ end
38
+
39
+ puts "\nTotal time across all searches (ms): #{total_duration}"
40
+ end
41
+
42
+ class XPathContext
43
+ alias_method :original_evaluate, :evaluate
44
+ def evaluate(search_path, handler=nil)
45
+ puts search_path if ENV['VERBOSE_PROFILE']
46
+
47
+ PROFILE_DATA[search_path] ||= Hash.new(0)
48
+ PROFILE_DATA[search_path][:count] += 1
49
+
50
+ start_time = Time.now
51
+ original_evaluate(search_path, handler).tap do
52
+ PROFILE_DATA[search_path][:time] += Time.now - start_time
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ # rubocop:enable Style/Documentation
@@ -82,6 +82,12 @@ module Kitchen
82
82
  as_type
83
83
  end
84
84
 
85
+ # Returns true if the query has the substitution character ('$')
86
+ #
87
+ def expects_substitution?
88
+ css_or_xpath.nil? || [css_or_xpath].flatten.all? { |item| item.include?('$') }
89
+ end
90
+
85
91
  protected
86
92
 
87
93
  def condition_passes?(method_or_callable, element, success_outcome)