ruby-structured-data 0.1.1

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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +252 -0
  5. data/data/schema_org_v30.json +20081 -0
  6. data/lib/generators/structured_data/install/install_generator.rb +17 -0
  7. data/lib/generators/structured_data/install/templates/structured_data.rb +15 -0
  8. data/lib/ruby-structured-data.rb +3 -0
  9. data/lib/ruby_structured_data.rb +4 -0
  10. data/lib/structured_data/compiler/parser.rb +120 -0
  11. data/lib/structured_data/compiler/terms.rb +24 -0
  12. data/lib/structured_data/compiler/uri_helper.rb +66 -0
  13. data/lib/structured_data/compiler/vocabulary.rb +115 -0
  14. data/lib/structured_data/compiler.rb +68 -0
  15. data/lib/structured_data/configuration.rb +19 -0
  16. data/lib/structured_data/document.rb +99 -0
  17. data/lib/structured_data/enum.rb +64 -0
  18. data/lib/structured_data/error.rb +19 -0
  19. data/lib/structured_data/list.rb +46 -0
  20. data/lib/structured_data/node.rb +133 -0
  21. data/lib/structured_data/rails/helper.rb +38 -0
  22. data/lib/structured_data/rails/railtie.rb +22 -0
  23. data/lib/structured_data/rails/registry.rb +124 -0
  24. data/lib/structured_data/rails/renderer.rb +20 -0
  25. data/lib/structured_data/rails.rb +6 -0
  26. data/lib/structured_data/reference.rb +46 -0
  27. data/lib/structured_data/serializer.rb +44 -0
  28. data/lib/structured_data/validator/context.rb +30 -0
  29. data/lib/structured_data/validator/diagnostic.rb +45 -0
  30. data/lib/structured_data/validator/node_validator.rb +131 -0
  31. data/lib/structured_data/validator/range_checker.rb +83 -0
  32. data/lib/structured_data/validator/result.rb +37 -0
  33. data/lib/structured_data/validator.rb +76 -0
  34. data/lib/structured_data/values.rb +148 -0
  35. data/lib/structured_data/version.rb +5 -0
  36. data/lib/structured_data/vocabulary/data_loader.rb +25 -0
  37. data/lib/structured_data/vocabulary/hierarchy.rb +32 -0
  38. data/lib/structured_data/vocabulary/registry.rb +90 -0
  39. data/lib/structured_data/vocabulary.rb +136 -0
  40. data/lib/structured_data.rb +84 -0
  41. metadata +252 -0
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module StructuredData
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Copies StructuredData initializer to your application."
11
+
12
+ def copy_initializer
13
+ copy_file "structured_data.rb", "config/initializers/structured_data.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ StructuredData.configure do |config|
4
+ # Validation mode: :strict, :schema_org (default), or :none
5
+ config.validation_mode = :schema_org
6
+
7
+ # Output pretty formatted JSON-LD
8
+ config.pretty = Rails.env.development?
9
+ end
10
+
11
+ # Register controller/action structured data builders:
12
+ # StructuredData::Rails::Registry.register("landing#index", LandingPageBuilder)
13
+ # StructuredData::Rails::Registry.register("products#show") do |context|
14
+ # StructuredData.node("Product", name: context.product.name)
15
+ # end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ruby_structured_data"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "structured_data/version"
4
+ require_relative "structured_data"
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "terms"
4
+ require_relative "uri_helper"
5
+
6
+ module StructuredData
7
+ class Compiler
8
+ class Parser
9
+ include UriHelper
10
+
11
+ LINE_PATTERN = Regexp.new(
12
+ "\\A(?<subject><(?>(?:[^>\\\\]|\\\\.)+)>|_:[a-zA-Z0-9_.-]+)\\s+" \
13
+ "(?<predicate><(?>(?:[^>\\\\]|\\\\.)+)>)\\s+" \
14
+ "(?<object><(?>(?:[^>\\\\]|\\\\.)+)>|_:[a-zA-Z0-9_.-]+|" \
15
+ "\"(?>(?:[^\"\\\\]|\\\\.)*)\"(?:@[\\w-]+|\\^\\^<(?>(?:[^>\\\\]|\\\\.)+)>)?)" \
16
+ "\\s*\\.\\s*(?:#.*)?\\z"
17
+ )
18
+
19
+ def initialize(vocab)
20
+ @vocab = vocab
21
+ end
22
+
23
+ def parse(lines)
24
+ lines.each_with_index do |line, index|
25
+ parse_line(line, index + 1)
26
+ end
27
+ end
28
+
29
+ def parse_line(line, line_number)
30
+ stripped = line.strip
31
+ return if stripped.empty? || stripped.start_with?("#")
32
+
33
+ match = LINE_PATTERN.match(stripped)
34
+ raise_syntax_error(line, line_number, stripped) unless match
35
+
36
+ process_match(match)
37
+ end
38
+
39
+ private
40
+
41
+ def raise_syntax_error(line, line_number, stripped)
42
+ raise ParseError.new(
43
+ "Invalid N-Triples syntax at line #{line_number}: #{stripped}",
44
+ line_number: line_number,
45
+ line: line
46
+ )
47
+ end
48
+
49
+ def process_match(match)
50
+ subject_raw = match[:subject]
51
+ object_raw = match[:object]
52
+ return if subject_raw.start_with?("_:") || object_raw.start_with?("_:")
53
+
54
+ subject_term = extract_schema_term(extract_uri(subject_raw))
55
+ predicate_uri = extract_uri(match[:predicate])
56
+ return unless subject_term && predicate_uri
57
+
58
+ dispatch_predicate(subject_term, predicate_uri, object_raw)
59
+ end
60
+
61
+ def dispatch_predicate(subject_term, predicate_uri, object_raw)
62
+ case predicate_uri
63
+ when Terms::PREDICATES[:subclass_of] then handle_subclass_of(subject_term, object_raw)
64
+ when Terms::PREDICATES[:domain_includes] then handle_domain_includes(subject_term, object_raw)
65
+ when Terms::PREDICATES[:range_includes] then handle_range_includes(subject_term, object_raw)
66
+ when Terms::PREDICATES[:type] then handle_rdf_type(subject_term, object_raw)
67
+ when Terms::PREDICATES[:superseded_by] then handle_superseded_by(subject_term, object_raw)
68
+ when Terms::PREDICATES[:is_part_of] then handle_is_part_of(subject_term, object_raw)
69
+ end
70
+ end
71
+
72
+ def handle_subclass_of(subject_term, object_raw)
73
+ @vocab.add_class(subject_term)
74
+ parent_term = extract_schema_term(extract_uri(object_raw))
75
+ @vocab.add_subclass(subject_term, parent_term) if parent_term
76
+ end
77
+
78
+ def handle_domain_includes(subject_term, object_raw)
79
+ @vocab.add_property(subject_term)
80
+ domain_term = extract_schema_term(extract_uri(object_raw))
81
+ @vocab.add_domain(subject_term, domain_term) if domain_term
82
+ end
83
+
84
+ def handle_range_includes(subject_term, object_raw)
85
+ @vocab.add_property(subject_term)
86
+ range_term = extract_schema_term(extract_uri(object_raw))
87
+ @vocab.add_range(subject_term, range_term) if range_term
88
+ end
89
+
90
+ def handle_rdf_type(subject_term, object_raw)
91
+ object_uri = extract_uri(object_raw)
92
+ return unless object_uri
93
+
94
+ case object_uri
95
+ when Terms::TYPES[:rdfs_class], Terms::TYPES[:rdf_class]
96
+ @vocab.add_class(subject_term)
97
+ when Terms::TYPES[:rdf_property]
98
+ @vocab.add_property(subject_term)
99
+ else
100
+ handle_enum_type(subject_term, object_uri)
101
+ end
102
+ end
103
+
104
+ def handle_enum_type(subject_term, object_uri)
105
+ enum_type = extract_schema_term(object_uri)
106
+ @vocab.add_enum_member(enum_type, subject_term) if enum_type
107
+ end
108
+
109
+ def handle_superseded_by(subject_term, object_raw)
110
+ replacement = extract_schema_term(extract_uri(object_raw))
111
+ @vocab.add_superseded(subject_term, replacement) if replacement
112
+ end
113
+
114
+ def handle_is_part_of(subject_term, object_raw)
115
+ section = extract_section(object_raw)
116
+ @vocab.set_section(subject_term, section)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredData
4
+ class Compiler
5
+ module Terms
6
+ PREDICATES = {
7
+ subclass_of: "http://www.w3.org/2000/01/rdf-schema#subClassOf",
8
+ type: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
9
+ domain_includes: "https://schema.org/domainIncludes",
10
+ range_includes: "https://schema.org/rangeIncludes",
11
+ superseded_by: "https://schema.org/supersededBy",
12
+ is_part_of: "https://schema.org/isPartOf"
13
+ }.freeze
14
+
15
+ TYPES = {
16
+ rdfs_class: "http://www.w3.org/2000/01/rdf-schema#Class",
17
+ rdf_class: "http://www.w3.org/1999/02/22-rdf-syntax-ns#Class",
18
+ rdf_property: "http://www.w3.org/1999/02/22-rdf-syntax-ns#Property"
19
+ }.freeze
20
+
21
+ SCHEMA_PREFIX = "https://schema.org/"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredData
4
+ class Compiler
5
+ module UriHelper
6
+ ESCAPE_CHARS = {
7
+ "t" => "\t",
8
+ "b" => "\b",
9
+ "n" => "\n",
10
+ "r" => "\r",
11
+ "f" => "\f",
12
+ "\"" => "\"",
13
+ "'" => "'",
14
+ "\\" => "\\"
15
+ }.freeze
16
+
17
+ def extract_uri(raw)
18
+ return unless raw.start_with?("<")
19
+
20
+ normalize_uri(unescape(raw[1..-2]))
21
+ end
22
+
23
+ def normalize_uri(uri)
24
+ normalized = uri.sub(%r{\Ahttp://schema\.org(/|\z)}, "https://schema.org\\1")
25
+ normalized.sub(%r{\Ahttps://www\.w3\.org/}, "http://www.w3.org/")
26
+ end
27
+
28
+ def extract_schema_term(uri)
29
+ return unless uri =~ %r{\Ahttps://schema\.org/([^/]+)\z}
30
+
31
+ Regexp.last_match(1)
32
+ end
33
+
34
+ def extract_section(object_raw)
35
+ return object_raw[1..-2] if object_raw.start_with?('"') && object_raw.end_with?('"')
36
+
37
+ uri = unescape(object_raw.delete_prefix("<").delete_suffix(">"))
38
+ section_from_subdomain(uri) || section_from_path(uri) || Vocabulary::CORE_SECTION
39
+ end
40
+
41
+ def section_from_subdomain(uri)
42
+ return unless uri =~ %r{\Ahttps?://([a-zA-Z0-9_-]+)\.schema\.org}
43
+
44
+ subdomain = Regexp.last_match(1)
45
+ subdomain unless subdomain == "www"
46
+ end
47
+
48
+ def section_from_path(uri)
49
+ return unless uri =~ %r{\Ahttps?://(?:www\.)?schema\.org/(.+?)/?\z}
50
+
51
+ Regexp.last_match(1)
52
+ end
53
+
54
+ def unescape(str)
55
+ str.gsub(/\\(?:([tbnrf"'\\])|u([0-9A-Fa-f]{4})|U([0-9A-Fa-f]{8}))/) do
56
+ if (char = Regexp.last_match(1))
57
+ ESCAPE_CHARS[char]
58
+ else
59
+ hex = Regexp.last_match(2) || Regexp.last_match(3)
60
+ [hex.hex].pack("U*")
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredData
4
+ class Compiler
5
+ class Vocabulary
6
+ CORE_SECTION = "core"
7
+
8
+ def initialize(version)
9
+ @version = version
10
+ @data = {
11
+ classes: Set.new,
12
+ properties: Set.new,
13
+ parents: {},
14
+ domains: {},
15
+ ranges: {},
16
+ enums: {},
17
+ superseded: {},
18
+ sections: {}
19
+ }
20
+ end
21
+
22
+ def add_class(name)
23
+ @data[:classes].add(name)
24
+ end
25
+
26
+ def add_subclass(child, parent)
27
+ @data[:classes].add(child)
28
+ (@data[:parents][child] ||= []) << parent
29
+ end
30
+
31
+ def add_property(name)
32
+ @data[:properties].add(name)
33
+ end
34
+
35
+ def add_domain(property, domain)
36
+ @data[:properties].add(property)
37
+ (@data[:domains][property] ||= []) << domain
38
+ end
39
+
40
+ def add_range(property, range)
41
+ @data[:properties].add(property)
42
+ (@data[:ranges][property] ||= []) << range
43
+ end
44
+
45
+ def add_enum_member(enum_type, member)
46
+ (@data[:enums][enum_type] ||= []) << member
47
+ end
48
+
49
+ def add_superseded(old_term, new_term)
50
+ @data[:superseded][old_term] = new_term
51
+ end
52
+
53
+ def set_section(term, section)
54
+ @data[:sections][term] = section
55
+ end
56
+
57
+ def to_h
58
+ {
59
+ "version" => @version,
60
+ "types" => build_types,
61
+ "properties" => build_properties,
62
+ "enums" => build_enums,
63
+ "superseded" => build_superseded
64
+ }
65
+ end
66
+
67
+ private
68
+
69
+ def build_types
70
+ @data[:classes].to_a.sort.to_h do |type_name|
71
+ [
72
+ type_name,
73
+ {
74
+ "parents" => (@data[:parents][type_name] || []).sort.uniq,
75
+ "section" => @data[:sections][type_name] || CORE_SECTION
76
+ }
77
+ ]
78
+ end
79
+ end
80
+
81
+ def build_properties
82
+ @data[:properties].to_a.sort.to_h do |prop_name|
83
+ [
84
+ prop_name,
85
+ {
86
+ "domains" => (@data[:domains][prop_name] || []).sort.uniq,
87
+ "ranges" => (@data[:ranges][prop_name] || []).sort.uniq
88
+ }
89
+ ]
90
+ end
91
+ end
92
+
93
+ def build_enums
94
+ result = {}
95
+ @data[:enums].keys.sort.each do |enum_type|
96
+ members = clean_enum_members(@data[:enums][enum_type])
97
+ result[enum_type] = members unless members.empty?
98
+ end
99
+ result
100
+ end
101
+
102
+ def clean_enum_members(members)
103
+ classes = @data[:classes]
104
+ properties = @data[:properties]
105
+ members.reject { |m| classes.include?(m) || properties.include?(m) }.sort.uniq
106
+ end
107
+
108
+ def build_superseded
109
+ @data[:superseded].keys.sort.to_h do |term|
110
+ [term, @data[:superseded][term]]
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "compiler/vocabulary"
5
+ require_relative "compiler/parser"
6
+
7
+ module StructuredData
8
+ class Compiler
9
+ class ParseError < StandardError
10
+ attr_reader :line_number, :line
11
+
12
+ def initialize(message, line_number: nil, line: nil)
13
+ @line_number = line_number
14
+ @line = line
15
+ super(message)
16
+ end
17
+ end
18
+
19
+ class << self
20
+ def compile(source, version: "30.0")
21
+ new(source, version: version).compile
22
+ end
23
+
24
+ def compile_to_file(source_path, target_path, version: "30.0")
25
+ new(source_path, version: version).compile_to_file(target_path)
26
+ end
27
+ end
28
+
29
+ def initialize(source, version: "30.0")
30
+ @source = source
31
+ @version = version
32
+ end
33
+
34
+ def compile
35
+ @compile ||= begin
36
+ vocab = Vocabulary.new(@version)
37
+ Parser.new(vocab).parse(source_lines)
38
+ vocab.to_h
39
+ end
40
+ end
41
+
42
+ def compile_to_file(target_path)
43
+ result = compile
44
+ File.write(target_path, "#{JSON.pretty_generate(result)}\n")
45
+ result
46
+ end
47
+
48
+ private
49
+
50
+ def source_lines
51
+ if @source.respond_to?(:each_line) && !@source.is_a?(String)
52
+ @source.each_line
53
+ elsif @source.is_a?(String)
54
+ string_source_lines
55
+ else
56
+ raise ArgumentError, "Unsupported source: #{@source.inspect}"
57
+ end
58
+ end
59
+
60
+ def string_source_lines
61
+ if !@source.include?("\n") && File.file?(@source)
62
+ File.foreach(@source)
63
+ else
64
+ @source.each_line
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredData
4
+ class Configuration
5
+ DEFAULT_VALIDATION_MODE = :schema_org
6
+ DEFAULT_PRETTY = false
7
+
8
+ attr_accessor :validation_mode, :pretty
9
+
10
+ def initialize
11
+ reset!
12
+ end
13
+
14
+ def reset!
15
+ @validation_mode = DEFAULT_VALIDATION_MODE
16
+ @pretty = DEFAULT_PRETTY
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "error"
5
+
6
+ module StructuredData
7
+ class Document
8
+ class DuplicateIdError < StructuredData::Error; end
9
+
10
+ DEFAULT_CONTEXT = "https://schema.org"
11
+
12
+ attr_reader :context
13
+
14
+ def initialize(*nodes, context: DEFAULT_CONTEXT, graph: nil)
15
+ @context = context
16
+ @graph = graph
17
+ @nodes = []
18
+ nodes.flatten.each { |node| add(node) }
19
+ end
20
+
21
+ def nodes
22
+ @nodes.dup.freeze
23
+ end
24
+
25
+ def graph?
26
+ @nodes.size != 1 || @graph == true
27
+ end
28
+
29
+ def add(node)
30
+ if node.is_a?(Array)
31
+ node.each { |item| add(item) }
32
+ else
33
+ validate_node!(node)
34
+ check_duplicate_id!(node)
35
+ @nodes << node
36
+ end
37
+ self
38
+ end
39
+ alias << add
40
+
41
+ def to_h
42
+ if graph?
43
+ {
44
+ "@context" => context,
45
+ "@graph" => @nodes.map(&:to_h)
46
+ }
47
+ else
48
+ { "@context" => context }.merge(@nodes.first.to_h)
49
+ end
50
+ end
51
+
52
+ def as_json(*)
53
+ to_h
54
+ end
55
+
56
+ def to_json(*)
57
+ to_h.to_json(*)
58
+ end
59
+
60
+ def ==(other)
61
+ other.is_a?(self.class) &&
62
+ context == other.context &&
63
+ graph? == other.graph? &&
64
+ nodes == other.nodes
65
+ end
66
+ alias eql? ==
67
+
68
+ def hash
69
+ [self.class, context, graph?, nodes].hash
70
+ end
71
+
72
+ private
73
+
74
+ def validate_node!(node)
75
+ raise ArgumentError, "Node cannot be nil" if node.nil?
76
+ raise ArgumentError, "Node must respond to #to_h" unless node.respond_to?(:to_h)
77
+ end
78
+
79
+ def check_duplicate_id!(node)
80
+ id = explicit_id(node)
81
+ return unless id
82
+
83
+ existing = @nodes.find { |n| explicit_id(n) == id }
84
+ return unless existing && existing != node
85
+
86
+ raise DuplicateIdError, "Conflicting duplicate @id detected: #{id}"
87
+ end
88
+
89
+ def explicit_id(node)
90
+ return nil unless node.respond_to?(:id)
91
+
92
+ raw_id = node.id
93
+ return nil if raw_id.nil?
94
+
95
+ str_id = raw_id.to_s.strip
96
+ str_id.empty? ? nil : str_id
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module StructuredData
6
+ class Enum
7
+ SCHEMA_ORG_URI_PREFIX = "https://schema.org/"
8
+
9
+ attr_reader :name, :type, :uri
10
+
11
+ def initialize(name, type = nil)
12
+ raise ArgumentError, "name cannot be blank" if name.nil? || name.to_s.strip.empty?
13
+
14
+ @name = normalize_term(name)
15
+ @type = blank?(type) ? nil : normalize_term(type)
16
+ @uri = "#{SCHEMA_ORG_URI_PREFIX}#{@name}"
17
+ end
18
+
19
+ def to_s
20
+ uri
21
+ end
22
+ alias to_str to_s
23
+
24
+ def to_h
25
+ uri
26
+ end
27
+
28
+ def as_json(*)
29
+ uri
30
+ end
31
+
32
+ def to_json(*)
33
+ uri.to_json(*)
34
+ end
35
+
36
+ def ==(other)
37
+ if other.is_a?(self.class)
38
+ uri == other.uri
39
+ elsif other.is_a?(String)
40
+ uri == other
41
+ else
42
+ false
43
+ end
44
+ end
45
+
46
+ def eql?(other)
47
+ other.is_a?(self.class) && uri == other.uri
48
+ end
49
+
50
+ def hash
51
+ [self.class, uri].hash
52
+ end
53
+
54
+ private
55
+
56
+ def blank?(val)
57
+ val.nil? || (val.respond_to?(:to_s) && val.to_s.strip.empty?)
58
+ end
59
+
60
+ def normalize_term(term)
61
+ term.to_s.strip.sub(%r{\Ahttps?://schema\.org/}, "")
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredData
4
+ class Error < StandardError; end
5
+
6
+ class ValidationError < Error
7
+ attr_reader :result
8
+
9
+ def initialize(result_or_message = nil)
10
+ if result_or_message.respond_to?(:errors)
11
+ @result = result_or_message
12
+ errors_str = @result.errors.map { |e| "#{e.path}: #{e.message}" }.join("; ")
13
+ super("Validation failed: #{errors_str}")
14
+ else
15
+ super(result_or_message || "Validation failed")
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredData
4
+ class List
5
+ include Enumerable
6
+
7
+ attr_reader :items
8
+
9
+ def initialize(items = [])
10
+ raise ArgumentError, "items must be Enumerable" unless items.is_a?(Enumerable) || items.respond_to?(:to_a)
11
+
12
+ @items = items.to_a
13
+ end
14
+
15
+ def each(&)
16
+ items.each(&)
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ "@list" => items.map { |item| serialize_item(item) }
22
+ }
23
+ end
24
+
25
+ def ==(other)
26
+ other.is_a?(self.class) && items == other.items
27
+ end
28
+ alias eql? ==
29
+
30
+ def hash
31
+ [self.class, items].hash
32
+ end
33
+
34
+ private
35
+
36
+ def serialize_item(item)
37
+ if item.is_a?(Array)
38
+ item.map { |nested| serialize_item(nested) }
39
+ elsif item.respond_to?(:to_h)
40
+ item.to_h
41
+ else
42
+ item
43
+ end
44
+ end
45
+ end
46
+ end