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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredData
4
+ class Validator
5
+ class RangeChecker
6
+ DATA_TYPES = %w[Text URL Date DateTime Time XPathType CssSelectorType PronounceableText DataType].freeze
7
+ NUMBER_TYPES = %w[Number Integer Float DataType].freeze
8
+ BOOLEAN_TYPES = %w[Boolean DataType].freeze
9
+ DATE_TIME_TYPES = %w[Date DateTime Time Text DataType].freeze
10
+ URL_TEXT_TYPES = %w[URL Text DataType].freeze
11
+ ENUM_FALLBACK_TYPES = %w[Text URL DataType Enumeration].freeze
12
+
13
+ attr_reader :vocabulary
14
+
15
+ def initialize(vocabulary)
16
+ @vocabulary = vocabulary
17
+ end
18
+
19
+ def matches?(val, ranges)
20
+ return true if ranges.empty?
21
+
22
+ case val
23
+ when Node
24
+ node_matches?(val, ranges)
25
+ when Reference
26
+ true
27
+ when Enum
28
+ enum_matches?(val, ranges)
29
+ when Values::Url, URI::Generic, Values::Text
30
+ ranges.intersect?(URL_TEXT_TYPES)
31
+ else
32
+ primitive_matches?(val, ranges)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def node_matches?(val, ranges)
39
+ return true if ranges.include?(val.type) || ranges.include?("Thing")
40
+
41
+ vocabulary.ancestors(val.type).intersect?(ranges)
42
+ end
43
+
44
+ def enum_matches?(val, ranges)
45
+ if val.type
46
+ return false unless enum_type_matches?(val, ranges)
47
+ return false if vocabulary.enum_type?(val.type) && !vocabulary.enum_member?(val.type, val.name)
48
+
49
+ return true
50
+ end
51
+
52
+ return true if ranges.intersect?(ENUM_FALLBACK_TYPES)
53
+
54
+ ranges.any? { |range| vocabulary.enum_member?(range, val.name) }
55
+ end
56
+
57
+ def enum_type_matches?(val, ranges)
58
+ ranges.include?(val.type) || vocabulary.ancestors(val.type).intersect?(ranges)
59
+ end
60
+
61
+ def primitive_matches?(val, ranges)
62
+ case val
63
+ when String
64
+ string_matches?(val, ranges)
65
+ when Integer, Float, Numeric
66
+ ranges.intersect?(NUMBER_TYPES)
67
+ when TrueClass, FalseClass
68
+ ranges.intersect?(BOOLEAN_TYPES)
69
+ when Date, Time
70
+ ranges.intersect?(DATE_TIME_TYPES)
71
+ else
72
+ false
73
+ end
74
+ end
75
+
76
+ def string_matches?(val, ranges)
77
+ return true if ranges.intersect?(DATA_TYPES)
78
+
79
+ ranges.any? { |range| vocabulary.enum_member?(range, val) }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diagnostic"
4
+
5
+ module StructuredData
6
+ class Validator
7
+ class Result
8
+ attr_reader :diagnostics
9
+
10
+ def initialize(diagnostics = [])
11
+ @diagnostics = diagnostics.dup.freeze
12
+ end
13
+
14
+ def valid?
15
+ errors.empty?
16
+ end
17
+
18
+ def errors
19
+ diagnostics.select(&:error?)
20
+ end
21
+
22
+ def warnings
23
+ diagnostics.select(&:warning?)
24
+ end
25
+
26
+ def ==(other)
27
+ other.is_a?(self.class) && diagnostics == other.diagnostics
28
+ end
29
+ alias eql? ==
30
+
31
+ def hash
32
+ [self.class, diagnostics].hash
33
+ end
34
+ end
35
+ end
36
+ ValidationResult = Validator::Result
37
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "validator/diagnostic"
4
+ require_relative "validator/result"
5
+ require_relative "validator/context"
6
+ require_relative "validator/range_checker"
7
+ require_relative "validator/node_validator"
8
+
9
+ module StructuredData
10
+ class Validator
11
+ VALID_MODES = %i[schema_org strict none].freeze
12
+
13
+ class << self
14
+ def validate(target, mode: :schema_org, vocabulary: StructuredData::Vocabulary.default)
15
+ new(vocabulary: vocabulary).validate(target, mode: mode)
16
+ end
17
+
18
+ def validate!(target, mode: :schema_org, vocabulary: StructuredData::Vocabulary.default)
19
+ new(vocabulary: vocabulary).validate!(target, mode: mode)
20
+ end
21
+ end
22
+
23
+ attr_reader :vocabulary, :node_validator
24
+
25
+ def initialize(vocabulary: StructuredData::Vocabulary.default)
26
+ @vocabulary = vocabulary
27
+ range_checker = RangeChecker.new(vocabulary)
28
+ @node_validator = NodeValidator.new(vocabulary, range_checker)
29
+ end
30
+
31
+ def validate(target, mode: :schema_org)
32
+ validate_mode!(mode)
33
+ return Result.new if mode == :none
34
+
35
+ diagnostics = []
36
+ validate_target(target, Context.new(mode, diagnostics))
37
+ Result.new(diagnostics)
38
+ end
39
+
40
+ def validate!(target, mode: :schema_org)
41
+ result = validate(target, mode: mode)
42
+ raise ValidationError, result unless result.valid?
43
+
44
+ result
45
+ end
46
+
47
+ private
48
+
49
+ def validate_mode!(mode)
50
+ return if VALID_MODES.include?(mode)
51
+
52
+ raise ArgumentError, "Invalid validation mode: #{mode.inspect}"
53
+ end
54
+
55
+ def validate_target(target, context)
56
+ case target
57
+ when Document
58
+ validate_document(target, context)
59
+ when Node
60
+ node_validator.validate(target, "$", context)
61
+ else
62
+ raise ArgumentError, "Target must be a StructuredData::Node or StructuredData::Document"
63
+ end
64
+ end
65
+
66
+ def validate_document(document, context)
67
+ if document.nodes.size == 1 && !document.graph?
68
+ node_validator.validate(document.nodes.first, "$", context)
69
+ else
70
+ document.nodes.each_with_index do |node, idx|
71
+ node_validator.validate(node, "$.@graph[#{idx}]", context)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "json"
5
+ require_relative "reference"
6
+ require_relative "list"
7
+ require_relative "enum"
8
+
9
+ module StructuredData
10
+ module Values
11
+ class Url
12
+ attr_reader :value
13
+
14
+ def initialize(value)
15
+ validate_url!(value)
16
+ @value = value.to_s
17
+ end
18
+
19
+ def to_s
20
+ value
21
+ end
22
+ alias to_str to_s
23
+
24
+ def to_h
25
+ value
26
+ end
27
+
28
+ def as_json(*)
29
+ value
30
+ end
31
+
32
+ def to_json(*)
33
+ value.to_json(*)
34
+ end
35
+
36
+ def ==(other)
37
+ if other.is_a?(self.class)
38
+ value == other.value
39
+ elsif other.is_a?(String)
40
+ value == other
41
+ else
42
+ false
43
+ end
44
+ end
45
+
46
+ def eql?(other)
47
+ other.is_a?(self.class) && value == other.value
48
+ end
49
+
50
+ def hash
51
+ [self.class, value].hash
52
+ end
53
+
54
+ private
55
+
56
+ def validate_url!(val)
57
+ raise ArgumentError, "URL cannot be blank" if val.nil? || val.to_s.strip.empty?
58
+
59
+ check_parsed_uri!(parse_uri(val), val)
60
+ end
61
+
62
+ def parse_uri(val)
63
+ val.is_a?(URI::Generic) ? val : URI.parse(val.to_s)
64
+ rescue URI::InvalidURIError => e
65
+ raise ArgumentError, "Invalid URL format: #{val} (#{e.message})"
66
+ end
67
+
68
+ def check_parsed_uri!(uri, raw)
69
+ raise ArgumentError, "Invalid URL format: #{raw}" if uri.scheme.nil? || uri.scheme.empty?
70
+ raise ArgumentError, "Invalid URL format: #{raw}" if empty_location?(uri)
71
+ end
72
+
73
+ def empty_location?(uri)
74
+ (uri.host.nil? || uri.host.empty?) && (uri.path.nil? || uri.path.empty?)
75
+ end
76
+ end
77
+
78
+ class Text
79
+ attr_reader :value
80
+
81
+ def initialize(value)
82
+ raise ArgumentError, "Text cannot be nil" if value.nil?
83
+
84
+ @value = value.to_s
85
+ end
86
+
87
+ def to_s
88
+ value
89
+ end
90
+ alias to_str to_s
91
+
92
+ def to_h
93
+ value
94
+ end
95
+
96
+ def as_json(*)
97
+ value
98
+ end
99
+
100
+ def to_json(*)
101
+ value.to_json(*)
102
+ end
103
+
104
+ def ==(other)
105
+ if other.is_a?(self.class)
106
+ value == other.value
107
+ elsif other.is_a?(String)
108
+ value == other
109
+ else
110
+ false
111
+ end
112
+ end
113
+
114
+ def eql?(other)
115
+ other.is_a?(self.class) && value == other.value
116
+ end
117
+
118
+ def hash
119
+ [self.class, value].hash
120
+ end
121
+ end
122
+
123
+ module_function
124
+
125
+ def url(val)
126
+ val.is_a?(Url) ? val : Url.new(val)
127
+ end
128
+
129
+ def text(val)
130
+ val.is_a?(Text) ? val : Text.new(val)
131
+ end
132
+
133
+ def ref(id)
134
+ StructuredData::Reference.new(id)
135
+ end
136
+
137
+ def list(*items)
138
+ StructuredData::List.new(items.flatten)
139
+ end
140
+
141
+ def enum(name, type = nil)
142
+ StructuredData::Enum.new(name, type)
143
+ end
144
+ end
145
+
146
+ Url = Values::Url
147
+ Text = Values::Text
148
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredData
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module StructuredData
7
+ class Vocabulary
8
+ module DataLoader
9
+ DEFAULT_DATA_PATH = File.expand_path("../../../data/schema_org_v30.json", __dir__)
10
+
11
+ def self.load(source)
12
+ case source
13
+ when nil
14
+ JSON.parse(File.read(DEFAULT_DATA_PATH))
15
+ when Hash
16
+ source
17
+ when String, Pathname
18
+ JSON.parse(File.read(source.to_s))
19
+ else
20
+ raise ArgumentError, "Invalid vocabulary source: #{source.inspect}"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredData
4
+ class Vocabulary
5
+ module Hierarchy
6
+ def self.ancestors(type_name, vocabulary)
7
+ normalized = vocabulary.normalize_term(type_name)
8
+ return [] if normalized.empty?
9
+
10
+ result = []
11
+ visited = Set.new([normalized])
12
+ queue = direct_parents(normalized, vocabulary)
13
+
14
+ until queue.empty?
15
+ current = queue.shift
16
+ next if visited.include?(current)
17
+
18
+ visited.add(current)
19
+ result << current
20
+ queue.concat(direct_parents(current, vocabulary))
21
+ end
22
+
23
+ result
24
+ end
25
+
26
+ def self.direct_parents(type_name, vocabulary)
27
+ info = vocabulary.type_info(type_name)
28
+ Array(info && (info["parents"] || info[:parents])).map { |p| vocabulary.normalize_term(p) }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredData
4
+ class Vocabulary
5
+ class Registry
6
+ def initialize
7
+ @vocabularies = {}
8
+ end
9
+
10
+ def register(name, data)
11
+ raise ArgumentError, "Vocabulary data must be a Hash" unless data.is_a?(Hash)
12
+
13
+ @vocabularies[name.to_s] = normalize_vocab_data(data)
14
+ end
15
+
16
+ def find(category, key)
17
+ if key.include?(":")
18
+ prefix, local = key.split(":", 2)
19
+ val = @vocabularies[prefix]&.dig(category, local)
20
+ return val if val
21
+ end
22
+
23
+ @vocabularies.each_value do |vocab|
24
+ val = vocab.dig(category, key)
25
+ return val if val
26
+ end
27
+ nil
28
+ end
29
+
30
+ def enum_defined?(name)
31
+ @vocabularies.each_value.any? { |vocab| vocab.dig("enums", name) }
32
+ end
33
+
34
+ def all_types
35
+ @vocabularies.each_value.flat_map { |vocab| vocab["types"].keys }
36
+ end
37
+
38
+ def all_properties
39
+ @vocabularies.each_value.flat_map { |vocab| vocab["properties"].keys }
40
+ end
41
+
42
+ private
43
+
44
+ def normalize_vocab_data(data)
45
+ {
46
+ "types" => normalize_subhash(data["types"] || data[:types]),
47
+ "properties" => normalize_subhash(data["properties"] || data[:properties]),
48
+ "enums" => normalize_enums(data["enums"] || data[:enums]),
49
+ "superseded" => normalize_string_map(data["superseded"] || data[:superseded])
50
+ }
51
+ end
52
+
53
+ def normalize_subhash(hash)
54
+ return {} unless hash.is_a?(Hash)
55
+
56
+ hash.each_with_object({}) do |(key, value), result|
57
+ norm_key = normalize_term(key)
58
+ result[norm_key] = value.is_a?(Hash) ? value.transform_keys(&:to_s) : value
59
+ end
60
+ end
61
+
62
+ def normalize_enums(hash)
63
+ return {} unless hash.is_a?(Hash)
64
+
65
+ hash.each_with_object({}) do |(key, members), result|
66
+ result[normalize_term(key)] = normalize_term_list(members)
67
+ end
68
+ end
69
+
70
+ def normalize_term_list(list)
71
+ Array(list).map { |term| normalize_term(term) }
72
+ end
73
+
74
+ def normalize_string_map(hash)
75
+ return {} unless hash.is_a?(Hash)
76
+
77
+ hash.each_with_object({}) do |(key, value), result|
78
+ result[normalize_term(key)] = normalize_term(value)
79
+ end
80
+ end
81
+
82
+ def normalize_term(term)
83
+ return "" if term.nil?
84
+
85
+ str = term.respond_to?(:name) && !term.is_a?(Class) ? term.name.to_s : term.to_s
86
+ str.strip.sub(%r{\Ahttps?://schema\.org/}, "")
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require_relative "vocabulary/registry"
5
+ require_relative "vocabulary/data_loader"
6
+ require_relative "vocabulary/hierarchy"
7
+
8
+ module StructuredData
9
+ class Vocabulary
10
+ class << self
11
+ extend Forwardable
12
+
13
+ def default
14
+ @default ||= new
15
+ end
16
+
17
+ def reset!
18
+ @default = nil
19
+ end
20
+
21
+ def_delegators :default,
22
+ :type_defined?, :type_info, :ancestors, :property_defined?, :property_info,
23
+ :property_allowed_for?, :expected_ranges, :enum_type?, :enum_member?,
24
+ :superseded_by, :register_vocabulary, :all_types, :all_properties
25
+ end
26
+
27
+ def initialize(source = nil)
28
+ @registry = Registry.new
29
+ @data = DataLoader.load(source)
30
+ end
31
+
32
+ def type_defined?(type_name)
33
+ !type_info(type_name).nil?
34
+ end
35
+
36
+ def type_info(type_name)
37
+ lookup_category("types", type_name)
38
+ end
39
+
40
+ def ancestors(type_name)
41
+ Hierarchy.ancestors(type_name, self)
42
+ end
43
+
44
+ def property_defined?(property_name)
45
+ !property_info(property_name).nil?
46
+ end
47
+
48
+ def property_info(property_name)
49
+ lookup_category("properties", property_name)
50
+ end
51
+
52
+ def property_allowed_for?(type_name, property_name)
53
+ info = property_info(property_name)
54
+ return false unless info
55
+
56
+ domains = Array(info["domains"] || info[:domains]).map { |d| normalize_term(d) }
57
+ norm_type = normalize_term(type_name)
58
+ return true if domains.include?(norm_type)
59
+
60
+ ancestors(norm_type).any? { |ancestor| domains.include?(ancestor) }
61
+ end
62
+
63
+ def expected_ranges(property_name)
64
+ info = property_info(property_name)
65
+ return [] unless info
66
+
67
+ Array(info["ranges"] || info[:ranges]).map { |r| normalize_term(r) }
68
+ end
69
+
70
+ def enum_type?(type_name)
71
+ norm = normalize_term(type_name)
72
+ return false if norm.empty?
73
+
74
+ !@data.dig("enums", norm).nil? || @registry.enum_defined?(norm) ||
75
+ norm == "Enumeration" || ancestors(norm).include?("Enumeration")
76
+ end
77
+
78
+ def enum_member?(enum_type_name, member_name)
79
+ norm_enum = normalize_term(enum_type_name)
80
+ norm_member = normalize_term(member_name)
81
+ return false if norm_enum.empty? || norm_member.empty?
82
+
83
+ return true if enum_members(norm_enum).include?(norm_member)
84
+
85
+ ancestors(norm_enum).any? do |ancestor|
86
+ enum_members(ancestor).include?(norm_member)
87
+ end
88
+ end
89
+
90
+ def superseded_by(term)
91
+ norm = normalize_term(term)
92
+ return nil if norm.empty?
93
+
94
+ @registry.find("superseded", norm) || @data.dig("superseded", norm)
95
+ end
96
+
97
+ def register_vocabulary(name, data)
98
+ @registry.register(name, data)
99
+ self
100
+ end
101
+
102
+ def all_types
103
+ category_keys("types")
104
+ end
105
+
106
+ def all_properties
107
+ category_keys("properties")
108
+ end
109
+
110
+ def normalize_term(term)
111
+ return "" if term.nil?
112
+
113
+ str = term.respond_to?(:name) && !term.is_a?(Class) ? term.name.to_s : term.to_s
114
+ str.strip.sub(%r{\Ahttps?://schema\.org/}, "")
115
+ end
116
+
117
+ private
118
+
119
+ def lookup_category(category, term)
120
+ norm = normalize_term(term)
121
+ return nil if norm.empty?
122
+
123
+ @registry.find(category, norm) || @data.dig(category, norm)
124
+ end
125
+
126
+ def category_keys(category)
127
+ keys = @data[category] ? @data[category].keys : []
128
+ (keys + @registry.public_send("all_#{category}")).uniq.sort
129
+ end
130
+
131
+ def enum_members(norm_enum)
132
+ members = @registry.find("enums", norm_enum) || @data.dig("enums", norm_enum) || []
133
+ Array(members).map { |m| normalize_term(m) }
134
+ end
135
+ end
136
+ end