openstax_kitchen 2.0.0 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/devcontainer.json +37 -17
  3. data/.github/config.yml +14 -0
  4. data/.github/workflows/tests.yml +5 -15
  5. data/.gitignore +1 -1
  6. data/.inch.yml +6 -0
  7. data/.rubocop.yml +65 -0
  8. data/CHANGELOG.md +16 -0
  9. data/Gemfile +5 -3
  10. data/Gemfile.lock +54 -5
  11. data/README.md +58 -11
  12. data/Rakefile +5 -3
  13. data/bin/console +4 -3
  14. data/docker/Dockerfile +36 -0
  15. data/docker/Dockerfile.ci +10 -0
  16. data/docker/bash +5 -1
  17. data/docker/build +10 -0
  18. data/docker/ci +16 -0
  19. data/docker/run +9 -0
  20. data/docker/tag_and_push_latest +17 -0
  21. data/lefthook.yml +6 -0
  22. data/lib/kitchen/ancestor.rb +38 -1
  23. data/lib/kitchen/book_document.rb +20 -2
  24. data/lib/kitchen/book_element.rb +24 -3
  25. data/lib/kitchen/book_element_enumerator.rb +4 -0
  26. data/lib/kitchen/book_recipe.rb +15 -1
  27. data/lib/kitchen/chapter_element.rb +43 -3
  28. data/lib/kitchen/chapter_element_enumerator.rb +9 -1
  29. data/lib/kitchen/clipboard.rb +35 -4
  30. data/lib/kitchen/composite_chapter_element.rb +20 -1
  31. data/lib/kitchen/composite_page_element.rb +25 -2
  32. data/lib/kitchen/composite_page_element_enumerator.rb +8 -0
  33. data/lib/kitchen/config.rb +14 -7
  34. data/lib/kitchen/counter.rb +9 -2
  35. data/lib/kitchen/debug/print_recipe_error.rb +53 -35
  36. data/lib/kitchen/directions/.rubocop.yml +22 -0
  37. data/lib/kitchen/directions/bake_appendix.rb +4 -4
  38. data/lib/kitchen/directions/bake_chapter_glossary.rb +10 -7
  39. data/lib/kitchen/directions/bake_chapter_introductions.rb +6 -6
  40. data/lib/kitchen/directions/bake_chapter_key_equations.rb +9 -6
  41. data/lib/kitchen/directions/bake_chapter_summary.rb +16 -13
  42. data/lib/kitchen/directions/bake_chapter_title/main.rb +11 -0
  43. data/lib/kitchen/directions/bake_chapter_title/v1.rb +24 -0
  44. data/lib/kitchen/directions/bake_composite_pages.rb +2 -2
  45. data/lib/kitchen/directions/bake_example.rb +6 -4
  46. data/lib/kitchen/directions/bake_exercises/main.rb +11 -0
  47. data/lib/kitchen/directions/bake_exercises/v1.rb +166 -0
  48. data/lib/kitchen/directions/bake_figure.rb +8 -5
  49. data/lib/kitchen/directions/bake_footnotes/main.rb +2 -2
  50. data/lib/kitchen/directions/bake_footnotes/v1.rb +4 -4
  51. data/lib/kitchen/directions/bake_index/main.rb +2 -2
  52. data/lib/kitchen/directions/bake_index/v1.rb +22 -15
  53. data/lib/kitchen/directions/bake_link_placeholders.rb +24 -0
  54. data/lib/kitchen/directions/bake_math_in_paragraph.rb +5 -3
  55. data/lib/kitchen/directions/bake_notes.rb +8 -8
  56. data/lib/kitchen/directions/bake_numbered_table/main.rb +2 -2
  57. data/lib/kitchen/directions/bake_numbered_table/v1.rb +21 -16
  58. data/lib/kitchen/directions/bake_page_abstracts.rb +14 -0
  59. data/lib/kitchen/directions/bake_preface/main.rb +11 -0
  60. data/lib/kitchen/directions/bake_preface/v1.rb +18 -0
  61. data/lib/kitchen/directions/bake_stepwise.rb +7 -7
  62. data/lib/kitchen/directions/bake_suggested_reading.rb +26 -0
  63. data/lib/kitchen/directions/bake_toc.rb +41 -22
  64. data/lib/kitchen/directions/bake_unit_title/main.rb +11 -0
  65. data/lib/kitchen/directions/bake_unit_title/v1.rb +23 -0
  66. data/lib/kitchen/directions/bake_unnumbered_tables.rb +7 -5
  67. data/lib/kitchen/directions/move_title_text_into_span.rb +2 -2
  68. data/lib/kitchen/document.rb +72 -13
  69. data/lib/kitchen/element.rb +11 -0
  70. data/lib/kitchen/element_base.rb +276 -56
  71. data/lib/kitchen/element_enumerator.rb +8 -0
  72. data/lib/kitchen/element_enumerator_base.rb +210 -28
  73. data/lib/kitchen/element_enumerator_factory.rb +59 -52
  74. data/lib/kitchen/element_factory.rb +27 -12
  75. data/lib/kitchen/errors.rb +5 -0
  76. data/lib/kitchen/example_element.rb +19 -1
  77. data/lib/kitchen/example_element_enumerator.rb +9 -1
  78. data/lib/kitchen/figure_element.rb +36 -2
  79. data/lib/kitchen/figure_element_enumerator.rb +9 -1
  80. data/lib/kitchen/metadata_element.rb +28 -0
  81. data/lib/kitchen/metadata_element_enumerator.rb +21 -0
  82. data/lib/kitchen/mixins/block_error_if.rb +24 -4
  83. data/lib/kitchen/note_element.rb +37 -7
  84. data/lib/kitchen/note_element_enumerator.rb +9 -1
  85. data/lib/kitchen/oven.rb +66 -15
  86. data/lib/kitchen/page_element.rb +62 -13
  87. data/lib/kitchen/page_element_enumerator.rb +9 -1
  88. data/lib/kitchen/pantry.rb +28 -1
  89. data/lib/kitchen/patches/nokogiri.rb +19 -2
  90. data/lib/kitchen/patches/renderable.rb +9 -3
  91. data/lib/kitchen/patches/string.rb +8 -0
  92. data/lib/kitchen/recipe.rb +38 -34
  93. data/lib/kitchen/search_history.rb +43 -4
  94. data/lib/kitchen/search_query.rb +84 -0
  95. data/lib/kitchen/selectors/base.rb +26 -0
  96. data/lib/kitchen/selectors/standard_1.rb +8 -0
  97. data/lib/kitchen/table_element.rb +54 -3
  98. data/lib/kitchen/table_element_enumerator.rb +9 -1
  99. data/lib/kitchen/term_element.rb +15 -1
  100. data/lib/kitchen/term_element_enumerator.rb +9 -1
  101. data/lib/kitchen/transliterations.rb +7 -5
  102. data/lib/kitchen/type_casting_element_enumerator.rb +17 -1
  103. data/lib/kitchen/unit_element.rb +39 -0
  104. data/lib/kitchen/unit_element_enumerator.rb +20 -0
  105. data/lib/kitchen/utils.rb +10 -13
  106. data/lib/kitchen/version.rb +5 -1
  107. data/lib/locales/en.yml +6 -0
  108. data/lib/openstax_kitchen.rb +43 -42
  109. data/openstax_kitchen.gemspec +26 -20
  110. data/tutorials/00/solution1.rb +9 -0
  111. data/tutorials/00/solution2.rb +8 -0
  112. data/tutorials/01/solution1.rb +18 -0
  113. data/tutorials/01/solution2.rb +26 -0
  114. data/tutorials/02/solution1.rb +31 -0
  115. data/tutorials/03/{solution_1.rb → solution1.rb} +6 -4
  116. data/tutorials/03/solution2.rb +18 -0
  117. data/tutorials/04/{solution_1.rb → solution1.rb} +4 -2
  118. data/tutorials/04/{solution_2.rb → solution2.rb} +6 -4
  119. data/tutorials/05/solution1.rb +11 -0
  120. data/tutorials/check_it +16 -15
  121. data/tutorials/setup_my_recipes +7 -6
  122. metadata +101 -22
  123. data/Dockerfile +0 -19
  124. data/docker-compose.yml +0 -12
  125. data/docker/entrypoint +0 -9
  126. data/lib/kitchen/directions/bake_exercises.rb +0 -164
  127. data/tutorials/00/solution_1.rb +0 -7
  128. data/tutorials/00/solution_2.rb +0 -6
  129. data/tutorials/01/solution_1.rb +0 -16
  130. data/tutorials/01/solution_2.rb +0 -24
  131. data/tutorials/02/solution_1.rb +0 -29
  132. data/tutorials/03/solution_2.rb +0 -15
  133. data/tutorials/05/solution_1.rb +0 -9
@@ -1,32 +1,47 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Kitchen
4
+ # Builds Elements from Nokogiri Nodes
5
+ #
2
6
  class ElementFactory
3
7
 
4
8
  ELEMENT_CLASSES = ElementBase.descendants
5
9
 
6
- def self.specific_element_class_for_node(node)
7
- ELEMENT_CLASSES.find do |klass|
8
- klass.is_the_element_class_for?(node)
9
- end || Element
10
- end
11
-
10
+ # Builds a new concrete subclass of ElementBase for the provided node.
11
+ #
12
+ # @param node [Nokogiri::XML::Node] the node to wrap in an element
13
+ # @param document [Document] the document
14
+ # @param element_class [ElementBase] actually a subclass of +ElementBase+ to
15
+ # use when wrapping the node.
16
+ # @param default_short_type [Symbol, String] if we are making an instance
17
+ # of an element class where we know the short_type we'll use that; otherwise
18
+ # we'll make an Element instance and use this argument as the short_type.
19
+ # @param detect_element_class [Boolean] if true and +element_class+ is not given,
20
+ # attempts to detect the element class from the node
21
+ # @return [ElementBase] actually a subclass of +ElementBase+
22
+ #
12
23
  def self.build_from_node(node:,
13
24
  document:,
14
25
  element_class: nil,
15
26
  default_short_type: nil,
16
27
  detect_element_class: false)
17
- element_class ||= detect_element_class ?
18
- specific_element_class_for_node(node) :
19
- Element
28
+ element_class ||= detect_element_class ? specific_element_class_for_node(node) : Element
20
29
 
21
30
  if element_class == Element
22
31
  element_class.new(node: node,
23
- document: document,
24
- short_type: default_short_type)
32
+ document: document,
33
+ short_type: default_short_type)
25
34
  else
26
35
  element_class.new(node: node,
27
- document: document)
36
+ document: document)
28
37
  end
29
38
  end
30
39
 
40
+ def self.specific_element_class_for_node(node)
41
+ ELEMENT_CLASSES.find do |klass|
42
+ klass.is_the_element_class_for?(node)
43
+ end || Element
44
+ end
45
+
31
46
  end
32
47
  end
@@ -1,4 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Kitchen
4
+ # Raised likely due to a problem in the recipe, not in Kitchen
2
5
  class RecipeError < StandardError; end
6
+
7
+ # Raised when an element not found
3
8
  class ElementNotFoundError < StandardError; end
4
9
  end
@@ -1,6 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Kitchen
4
+ # An element for an example
5
+ #
2
6
  class ExampleElement < ElementBase
3
7
 
8
+ # Creates a new +ExampleElement+
9
+ #
10
+ # @param node [Nokogiri::XML::Node] the node this element wraps
11
+ # @param document [Document] this element's document
12
+ #
4
13
  def initialize(node:, document: nil)
5
14
  super(node: node,
6
15
  document: document,
@@ -8,12 +17,21 @@ module Kitchen
8
17
  short_type: :example)
9
18
  end
10
19
 
20
+ # Returns the an enumerator for titles.
21
+ #
22
+ # @return [ElementEnumerator]
23
+ #
11
24
  def titles
12
25
  search("span[data-type='title']")
13
26
  end
14
27
 
28
+ # Returns true if this class represents the element for the given node
29
+ #
30
+ # @param node [Nokogiri::XML::Node] the underlying node
31
+ # @return [Boolean]
32
+ #
15
33
  def self.is_the_element_class_for?(node)
16
- node['data-type'] == "example"
34
+ node['data-type'] == 'example'
17
35
  end
18
36
 
19
37
  end
@@ -1,9 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Kitchen
4
+ # An enumerator for example elements
5
+ #
2
6
  class ExampleElementEnumerator < ElementEnumeratorBase
3
7
 
8
+ # Returns a factory for this enumerator
9
+ #
10
+ # @return [ElementEnumeratorFactory]
11
+ #
4
12
  def self.factory
5
13
  ElementEnumeratorFactory.new(
6
- default_css_or_xpath: "div[data-type='example']", # TODO element.document.selectors.example
14
+ default_css_or_xpath: "div[data-type='example']", # TODO: element.document.selectors.example
7
15
  sub_element_class: ExampleElement,
8
16
  enumerator_class: self
9
17
  )
@@ -1,6 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Kitchen
4
+ # An element for a figure
5
+ #
2
6
  class FigureElement < ElementBase
3
7
 
8
+ # Creates a new +FigureElement+
9
+ #
10
+ # @param node [Nokogiri::XML::Node] the node this element wraps
11
+ # @param document [Document] this element's document
12
+ #
4
13
  def initialize(node:, document: nil)
5
14
  super(node: node,
6
15
  document: document,
@@ -8,12 +17,37 @@ module Kitchen
8
17
  short_type: :figure)
9
18
  end
10
19
 
20
+ # Returns the caption element
21
+ #
22
+ # @return [Element, nil]
23
+ #
11
24
  def caption
12
- first("figcaption")
25
+ first('figcaption')
26
+ end
27
+
28
+ # Returns the Figure Title
29
+ #
30
+ # @return [Element, nil]
31
+ #
32
+ def title
33
+ first("div[data-type='title']")
34
+ end
35
+
36
+ # Returns true if the figure is a child of another figure
37
+ #
38
+ # @return [Boolean]
39
+ #
40
+ def subfigure?
41
+ parent.name == 'figure'
13
42
  end
14
43
 
44
+ # Returns true if this class represents the element for the given node
45
+ #
46
+ # @param node [Nokogiri::XML::Node] the underlying node
47
+ # @return [Boolean]
48
+ #
15
49
  def self.is_the_element_class_for?(node)
16
- node.name == "figure"
50
+ node.name == 'figure'
17
51
  end
18
52
 
19
53
  end
@@ -1,9 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Kitchen
4
+ # An enumerator for figure elements
5
+ #
2
6
  class FigureElementEnumerator < ElementEnumeratorBase
3
7
 
8
+ # Returns a factory for this enumerator
9
+ #
10
+ # @return [ElementEnumeratorFactory]
11
+ #
4
12
  def self.factory
5
13
  ElementEnumeratorFactory.new(
6
- default_css_or_xpath: "figure", # TODO get from config?
14
+ default_css_or_xpath: 'figure', # TODO: get from config?
7
15
  sub_element_class: FigureElement,
8
16
  enumerator_class: self
9
17
  )
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitchen
4
+ # An element for metadata
5
+ #
6
+ class MetadataElement < ElementBase
7
+ # Creates a new +MetadataElement+
8
+ #
9
+ # @param node [Nokogiri::XML::Node] the node this element wraps
10
+ # @param document [Document] this element's document
11
+ #
12
+ def initialize(node:, document: nil)
13
+ super(node: node,
14
+ document: document,
15
+ enumerator_class: MetadataElementEnumerator,
16
+ short_type: :metadata)
17
+ end
18
+
19
+ # Returns set of selected data elements
20
+ #
21
+ # @return [ElementEnumerator]
22
+ #
23
+ def children_to_keep
24
+ search(%w([data-type='revised'] .authors .publishers .print-style .permissions
25
+ [data-type='subject']))
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitchen
4
+ # An enumerator for metadata elements
5
+ #
6
+ class MetadataElementEnumerator < ElementEnumeratorBase
7
+
8
+ # Returns a factory for this enumerator
9
+ #
10
+ # @return [ElementEnumeratorFactory]
11
+ #
12
+ def self.factory
13
+ ElementEnumeratorFactory.new(
14
+ default_css_or_xpath: "div[data-type='metadata']",
15
+ sub_element_class: MetadataElement,
16
+ enumerator_class: self
17
+ )
18
+ end
19
+
20
+ end
21
+ end
@@ -1,19 +1,39 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Kitchen
2
4
  module Mixins
5
+ # A mixin for including the block_error_if method
6
+ #
7
+ # @example
8
+ # class SomeClass
9
+ # include Mixins::BlockErrorIf
10
+ #
11
+ # def foo
12
+ # block_error_if(block_given?)
13
+ # end
14
+ # end
3
15
  module BlockErrorIf
4
-
16
+ # All Ruby methods can take blocks, but not all of them use the block. If a block is given but
17
+ # not expected, we want to raise an error to help the developer figure out why their block isn't
18
+ # doing what they expect. The method does some work to figure out where the block was errantly
19
+ # given to help the developer find the errant line of code.
20
+ #
21
+ # @param block_given [Boolean] true if block was given
22
+ # @raise [RecipeError] if a block was given
23
+ #
5
24
  def block_error_if(block_given)
25
+ return unless block_given
26
+
6
27
  calling_method = begin
7
28
  this_method_location_index = caller_locations.find_index do |location|
8
- location.label == "block_error_if"
29
+ location.label == 'block_error_if'
9
30
  end
10
31
 
11
32
  caller_locations[(this_method_location_index || -1) + 1].label
12
33
  end
13
34
 
14
- raise(RecipeError, "The `#{calling_method}` method does not take a block argument") if block_given
35
+ raise(RecipeError, "The `#{calling_method}` method does not take a block")
15
36
  end
16
-
17
37
  end
18
38
  end
19
39
  end
@@ -1,13 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Kitchen
4
+ # An element for a note
5
+ #
2
6
  class NoteElement < ElementBase
3
7
 
4
- TITLE_TRANSLATION_KEYS = %w(
8
+ TITLE_TRANSLATION_KEYS = %w[
5
9
  link-to-learning
6
10
  everyday-life
7
11
  sciences-interconnect
8
12
  chemist-portrait
9
- )
13
+ ].freeze
10
14
 
15
+ # Creates a new +NoteElement+
16
+ #
17
+ # @param node [Nokogiri::XML::Node] the node this element wraps
18
+ # @param document [Document] this element's document
19
+ #
11
20
  def initialize(node:, document: nil)
12
21
  super(node: node,
13
22
  document: document,
@@ -15,28 +24,49 @@ module Kitchen
15
24
  short_type: :note)
16
25
  end
17
26
 
27
+ # Returns the note's title element
28
+ #
29
+ # @return [Element, nil]
30
+ #
18
31
  def title
19
32
  block_error_if(block_given?)
20
33
  first("[data-type='title']")
21
34
  end
22
35
 
36
+ # Returns true if the note's title is autogenerated
37
+ #
38
+ # @return [Boolean]
39
+ #
23
40
  def indicates_autogenerated_title?
24
41
  translation_key_in(TITLE_TRANSLATION_KEYS).present?
25
42
  end
26
43
 
44
+ # Get the autogenerated title for this note
45
+ #
46
+ # @return [String]
47
+ #
27
48
  def autogenerated_title
28
49
  translation_key = translation_key_in(TITLE_TRANSLATION_KEYS)
29
- I18n.t(:"notes.#{document.short_name}.#{translation_key}", default: :"notes.#{translation_key}")
50
+ I18n.t(:"notes.#{document.short_name}.#{translation_key}",
51
+ default: :"notes.#{translation_key}")
30
52
  end
31
53
 
54
+ # Returns true if this class represents the element for the given node
55
+ #
56
+ # @param node [Nokogiri::XML::Node] the underlying node
57
+ # @return [Boolean]
58
+ #
59
+ def self.is_the_element_class_for?(node)
60
+ node['data-type'] == 'note'
61
+ end
62
+
63
+ protected
64
+
32
65
  def translation_key_in(possible_translation_keys)
33
66
  keys = possible_translation_keys & classes
34
67
  raise("too many translation keys: #{keys.join(', ')}") if keys.many?
35
- keys.first
36
- end
37
68
 
38
- def self.is_the_element_class_for?(node)
39
- node['data-type'] == "note"
69
+ keys.first
40
70
  end
41
71
 
42
72
  end
@@ -1,9 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Kitchen
4
+ # An enumerator for note elements
5
+ #
2
6
  class NoteElementEnumerator < ElementEnumeratorBase
3
7
 
8
+ # Returns a factory for this enumerator
9
+ #
10
+ # @return [ElementEnumeratorFactory]
11
+ #
4
12
  def self.factory
5
13
  ElementEnumeratorFactory.new(
6
- default_css_or_xpath: "div[data-type='note']", # TODO get from config?
14
+ default_css_or_xpath: "div[data-type='note']", # TODO: get from config?
7
15
  sub_element_class: NoteElement,
8
16
  enumerator_class: self
9
17
  )
data/lib/kitchen/oven.rb CHANGED
@@ -1,11 +1,18 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Kitchen
4
+ # A class for baking documents according to the instructions in recipes
5
+ #
2
6
  class Oven
3
7
 
4
- def self.bake(input_file:,
5
- config_file: nil,
6
- recipes:,
7
- output_file:)
8
-
8
+ # Bakes an input file using a recipe to produce an output file
9
+ #
10
+ # @param input_file [String] the path to the input file
11
+ # @param config_file [String] the path to the configuration file
12
+ # @param recipes [Array<Recipe>] an array of recipes with which to bake the document
13
+ # @param output_file [String] the path to the output file
14
+ #
15
+ def self.bake(input_file:, recipes:, output_file:, config_file: nil)
9
16
  profile = BakeProfile.new
10
17
  profile.started!
11
18
 
@@ -27,32 +34,76 @@ module Kitchen
27
34
  end
28
35
  profile.baked!
29
36
 
30
- File.open(output_file, "w") do |f|
31
- f.write doc.to_xhtml(indent:2)
37
+ File.open(output_file, 'w') do |f|
38
+ f.write doc.to_xhtml(indent: 2)
32
39
  end
33
40
  profile.written!
34
41
 
35
42
  profile
36
43
  end
37
44
 
45
+ # Stats on baking
46
+ #
38
47
  class BakeProfile
48
+ # Record that baking has started
39
49
  def started!; @started_at = Time.now; end
50
+
51
+ # Record that the input file has been opened
40
52
  def opened!; @opened_at = Time.now; end
53
+
54
+ # Record that the input file has been parsed
41
55
  def parsed!; @parsed_at = Time.now; end
56
+
57
+ # Record that the input file has been baked
42
58
  def baked!; @baked_at = Time.now; end
59
+
60
+ # Record that the output file has been written
43
61
  def written!; @written_at = Time.now; end
44
62
 
45
- def open_seconds; @opened_at - @started_at; end
46
- def parse_seconds; @parsed_at - @opened_at; end
47
- def bake_seconds; @baked_at - @parsed_at; end
48
- def write_seconds; @written_at - @baked_at; end
63
+ # Return the number of seconds it took to open the input file or nil if this
64
+ # info isn't available.
65
+ # @return [Float, nil]
66
+ def open_seconds
67
+ @opened_at - @started_at
68
+ rescue NoMethodError
69
+ nil
70
+ end
71
+
72
+ # Return the number of seconds it took to parse the input file after opening or
73
+ # nil if this info isn't available.
74
+ # @return [Float, nil]
75
+ def parse_seconds
76
+ @parsed_at - @opened_at
77
+ rescue NoMethodError
78
+ nil
79
+ end
80
+
81
+ # Return the number of seconds it took to bake the parsed file or nil if this
82
+ # info isn't available.
83
+ # @return [Float, nil]
84
+ def bake_seconds
85
+ @baked_at - @parsed_at
86
+ rescue NoMethodError
87
+ nil
88
+ end
89
+
90
+ # Return the number of seconds it took to write the baked file or nil if this
91
+ # info isn't available.
92
+ # @return [Float, nil]
93
+ def write_seconds
94
+ @written_at - @baked_at
95
+ rescue NoMethodError
96
+ nil
97
+ end
49
98
 
99
+ # Return the profile stats as a string
100
+ # @return [String]
50
101
  def to_s
51
102
  <<~STRING
52
- Open: #{open_seconds} s
53
- Parse: #{parse_seconds} s
54
- Bake: #{bake_seconds} s
55
- Write: #{write_seconds} s
103
+ Open: #{open_seconds || '??'} s
104
+ Parse: #{parse_seconds || '??'} s
105
+ Bake: #{bake_seconds || '??'} s
106
+ Write: #{write_seconds || '??'} s
56
107
  STRING
57
108
  end
58
109
  end