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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +252 -0
- data/data/schema_org_v30.json +20081 -0
- data/lib/generators/structured_data/install/install_generator.rb +17 -0
- data/lib/generators/structured_data/install/templates/structured_data.rb +15 -0
- data/lib/ruby-structured-data.rb +3 -0
- data/lib/ruby_structured_data.rb +4 -0
- data/lib/structured_data/compiler/parser.rb +120 -0
- data/lib/structured_data/compiler/terms.rb +24 -0
- data/lib/structured_data/compiler/uri_helper.rb +66 -0
- data/lib/structured_data/compiler/vocabulary.rb +115 -0
- data/lib/structured_data/compiler.rb +68 -0
- data/lib/structured_data/configuration.rb +19 -0
- data/lib/structured_data/document.rb +99 -0
- data/lib/structured_data/enum.rb +64 -0
- data/lib/structured_data/error.rb +19 -0
- data/lib/structured_data/list.rb +46 -0
- data/lib/structured_data/node.rb +133 -0
- data/lib/structured_data/rails/helper.rb +38 -0
- data/lib/structured_data/rails/railtie.rb +22 -0
- data/lib/structured_data/rails/registry.rb +124 -0
- data/lib/structured_data/rails/renderer.rb +20 -0
- data/lib/structured_data/rails.rb +6 -0
- data/lib/structured_data/reference.rb +46 -0
- data/lib/structured_data/serializer.rb +44 -0
- data/lib/structured_data/validator/context.rb +30 -0
- data/lib/structured_data/validator/diagnostic.rb +45 -0
- data/lib/structured_data/validator/node_validator.rb +131 -0
- data/lib/structured_data/validator/range_checker.rb +83 -0
- data/lib/structured_data/validator/result.rb +37 -0
- data/lib/structured_data/validator.rb +76 -0
- data/lib/structured_data/values.rb +148 -0
- data/lib/structured_data/version.rb +5 -0
- data/lib/structured_data/vocabulary/data_loader.rb +25 -0
- data/lib/structured_data/vocabulary/hierarchy.rb +32 -0
- data/lib/structured_data/vocabulary/registry.rb +90 -0
- data/lib/structured_data/vocabulary.rb +136 -0
- data/lib/structured_data.rb +84 -0
- metadata +252 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "reference"
|
|
5
|
+
require_relative "list"
|
|
6
|
+
require_relative "enum"
|
|
7
|
+
require_relative "values"
|
|
8
|
+
|
|
9
|
+
module StructuredData
|
|
10
|
+
class Node
|
|
11
|
+
attr_reader :type, :id, :attributes
|
|
12
|
+
|
|
13
|
+
def initialize(type, id: nil, **attributes, &block)
|
|
14
|
+
@type = normalize_type(type)
|
|
15
|
+
@id = id
|
|
16
|
+
@attributes = {}
|
|
17
|
+
attributes.each { |key, value| set(key, value) }
|
|
18
|
+
yield(self) if block_given?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def set(property, value)
|
|
22
|
+
key = to_lower_camel_case(property)
|
|
23
|
+
if id_key?(property, key)
|
|
24
|
+
@id = value
|
|
25
|
+
else
|
|
26
|
+
@attributes[key] = value
|
|
27
|
+
end
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def [](property)
|
|
32
|
+
key = to_lower_camel_case(property)
|
|
33
|
+
return id if id_key?(property, key)
|
|
34
|
+
|
|
35
|
+
attributes[key] || attributes[property.to_s] || attributes[property.to_sym]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ref
|
|
39
|
+
raise ArgumentError, "Cannot create reference without id" unless id_present?
|
|
40
|
+
|
|
41
|
+
StructuredData::Reference.new(id)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_h
|
|
45
|
+
result = { "@type" => type }
|
|
46
|
+
result["@id"] = id.to_s if id_present?
|
|
47
|
+
|
|
48
|
+
attributes.keys.map(&:to_s).sort.each do |key|
|
|
49
|
+
result[key] = serialize_value(attributes[key])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def as_json(*)
|
|
56
|
+
to_h
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_json(*)
|
|
60
|
+
to_h.to_json(*)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def ==(other)
|
|
64
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
65
|
+
end
|
|
66
|
+
alias eql? ==
|
|
67
|
+
|
|
68
|
+
def hash
|
|
69
|
+
[self.class, to_h].hash
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def id_present?
|
|
75
|
+
!id.nil? && !id.to_s.strip.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def id_key?(property, key)
|
|
79
|
+
key == "id" || property.to_s == "@id"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def blank?(val)
|
|
83
|
+
val.nil? || (val.respond_to?(:to_s) && val.to_s.strip.empty?)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_type(type)
|
|
87
|
+
raise ArgumentError, "type cannot be blank" if blank?(type)
|
|
88
|
+
|
|
89
|
+
clean = type.to_s.strip.sub(%r{\Ahttps?://schema\.org/}, "")
|
|
90
|
+
parts = clean.split("_").reject(&:empty?)
|
|
91
|
+
raise ArgumentError, "type cannot be blank" if parts.empty?
|
|
92
|
+
|
|
93
|
+
parts.map { |part| part[0].upcase + part[1..] }.join
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def to_lower_camel_case(prop)
|
|
97
|
+
str = prop.to_s.strip
|
|
98
|
+
parts = str.split("_").reject(&:empty?)
|
|
99
|
+
return str if parts.empty?
|
|
100
|
+
|
|
101
|
+
first_lower = parts.first[0].downcase + parts.first[1..]
|
|
102
|
+
camel_rest(first_lower, parts[1..])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def camel_rest(first, rest)
|
|
106
|
+
tail = rest.map { |part| part[0].upcase + part[1..] }
|
|
107
|
+
([first] + tail).join
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def serialize_value(val)
|
|
111
|
+
case val
|
|
112
|
+
when Node, Reference, List, Enum, Values::Url, Values::Text
|
|
113
|
+
val.to_h
|
|
114
|
+
when Array
|
|
115
|
+
val.map { |item| serialize_value(item) }
|
|
116
|
+
when Hash
|
|
117
|
+
val.transform_values { |item| serialize_value(item) }
|
|
118
|
+
when URI::Generic
|
|
119
|
+
val.to_s
|
|
120
|
+
else
|
|
121
|
+
serialize_fallback(val)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def serialize_fallback(val)
|
|
126
|
+
if val.respond_to?(:to_h)
|
|
127
|
+
val.to_h
|
|
128
|
+
else
|
|
129
|
+
val
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StructuredData
|
|
4
|
+
module Rails
|
|
5
|
+
module Helper
|
|
6
|
+
def structured_data_tag(document_or_node = nil)
|
|
7
|
+
return Renderer.render(document_or_node) if document_or_node
|
|
8
|
+
|
|
9
|
+
ctrl_path = structured_data_controller_path
|
|
10
|
+
act_name = structured_data_action_name
|
|
11
|
+
return nil unless ctrl_path && act_name
|
|
12
|
+
|
|
13
|
+
resolved = Registry.resolve(ctrl_path, act_name, self)
|
|
14
|
+
return nil if resolved.nil?
|
|
15
|
+
|
|
16
|
+
Renderer.render(resolved)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def structured_data_controller_path
|
|
22
|
+
if respond_to?(:controller_path) && controller_path
|
|
23
|
+
controller_path
|
|
24
|
+
elsif respond_to?(:controller) && controller.respond_to?(:controller_path)
|
|
25
|
+
controller.controller_path
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def structured_data_action_name
|
|
30
|
+
if respond_to?(:action_name) && action_name
|
|
31
|
+
action_name
|
|
32
|
+
elsif respond_to?(:controller) && controller.respond_to?(:action_name)
|
|
33
|
+
controller.action_name
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StructuredData
|
|
4
|
+
module Rails
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
config.structured_data = ActiveSupport::OrderedOptions.new
|
|
7
|
+
|
|
8
|
+
initializer "structured_data.setup" do |app|
|
|
9
|
+
options = app.config.structured_data
|
|
10
|
+
|
|
11
|
+
StructuredData.configure do |config|
|
|
12
|
+
config.validation_mode = options.validation_mode unless options.validation_mode.nil?
|
|
13
|
+
config.pretty = options.pretty unless options.pretty.nil?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
ActiveSupport.on_load(:action_view) do
|
|
17
|
+
include StructuredData::Rails::Helper
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module StructuredData
|
|
6
|
+
module Rails
|
|
7
|
+
class Registry
|
|
8
|
+
@entries = {}
|
|
9
|
+
@monitor = Monitor.new
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def register(target_or_controller, action_or_builder = nil, builder = nil, &block)
|
|
13
|
+
key, actual_builder = normalize_registration(
|
|
14
|
+
target_or_controller, action_or_builder, builder, block
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
@monitor.synchronize do
|
|
18
|
+
@entries[key] = actual_builder
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def resolve(controller_path, action_name, context = nil)
|
|
23
|
+
key = normalize_key("#{controller_path}##{action_name}")
|
|
24
|
+
builder = @monitor.synchronize { @entries[key] }
|
|
25
|
+
return nil if builder.nil?
|
|
26
|
+
|
|
27
|
+
execute(builder, context)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def clear!
|
|
31
|
+
@monitor.synchronize do
|
|
32
|
+
@entries.clear
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
alias reset! clear!
|
|
36
|
+
|
|
37
|
+
def registered?(target_or_controller, action = nil)
|
|
38
|
+
key = if action
|
|
39
|
+
normalize_key("#{target_or_controller}##{action}")
|
|
40
|
+
else
|
|
41
|
+
normalize_key(target_or_controller)
|
|
42
|
+
end
|
|
43
|
+
@monitor.synchronize { @entries.key?(key) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def normalize_registration(target, action_or_builder, builder, block)
|
|
49
|
+
if block
|
|
50
|
+
key = action_or_builder ? normalize_key("#{target}##{action_or_builder}") : normalize_key(target)
|
|
51
|
+
[key, block]
|
|
52
|
+
elsif builder
|
|
53
|
+
[normalize_key("#{target}##{action_or_builder}"), builder]
|
|
54
|
+
else
|
|
55
|
+
[normalize_key(target), action_or_builder]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize_key(key)
|
|
60
|
+
key.to_s.sub(%r{\A/+}, "")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def execute(builder, context)
|
|
64
|
+
if builder.is_a?(Class)
|
|
65
|
+
execute_class(builder, context)
|
|
66
|
+
elsif builder.respond_to?(:call)
|
|
67
|
+
execute_callable(builder, context)
|
|
68
|
+
elsif builder.respond_to?(:build)
|
|
69
|
+
execute_object_build(builder, context)
|
|
70
|
+
else
|
|
71
|
+
builder
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def execute_class(klass, context)
|
|
76
|
+
if klass.respond_to?(:build)
|
|
77
|
+
invoke_method(klass.method(:build), context)
|
|
78
|
+
elsif klass.respond_to?(:call)
|
|
79
|
+
invoke_method(klass.method(:call), context)
|
|
80
|
+
else
|
|
81
|
+
instantiate_and_invoke(klass, context)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def instantiate_and_invoke(klass, context)
|
|
86
|
+
init_arity = klass.instance_method(:initialize).arity
|
|
87
|
+
instance = init_arity.zero? ? klass.new : klass.new(context)
|
|
88
|
+
|
|
89
|
+
if instance.respond_to?(:build)
|
|
90
|
+
invoke_method(instance.method(:build), context)
|
|
91
|
+
elsif instance.respond_to?(:call)
|
|
92
|
+
invoke_method(instance.method(:call), context)
|
|
93
|
+
else
|
|
94
|
+
instance
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def execute_callable(callable, context)
|
|
99
|
+
if callable.respond_to?(:arity) && callable.arity.zero?
|
|
100
|
+
callable.call
|
|
101
|
+
else
|
|
102
|
+
callable.call(context)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def execute_object_build(obj, context)
|
|
107
|
+
if obj.method(:build).arity.zero?
|
|
108
|
+
obj.build
|
|
109
|
+
else
|
|
110
|
+
obj.build(context)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def invoke_method(method_obj, context)
|
|
115
|
+
if method_obj.arity.zero?
|
|
116
|
+
method_obj.call
|
|
117
|
+
else
|
|
118
|
+
method_obj.call(context)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/output_safety"
|
|
4
|
+
|
|
5
|
+
module StructuredData
|
|
6
|
+
module Rails
|
|
7
|
+
class Renderer
|
|
8
|
+
class << self
|
|
9
|
+
def render(document_or_node, pretty: StructuredData.config.pretty)
|
|
10
|
+
return nil if document_or_node.nil?
|
|
11
|
+
|
|
12
|
+
json = StructuredData::Serializer.dump(document_or_node, pretty: pretty)
|
|
13
|
+
html = %(<script type="application/ld+json">#{json}</script>)
|
|
14
|
+
|
|
15
|
+
ActiveSupport::SafeBuffer.new(html)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module StructuredData
|
|
6
|
+
class Reference
|
|
7
|
+
attr_reader :id
|
|
8
|
+
|
|
9
|
+
def initialize(id)
|
|
10
|
+
validate_id!(id)
|
|
11
|
+
@id = id
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_h
|
|
15
|
+
{ "@id" => id.to_s }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
id.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ==(other)
|
|
23
|
+
other.is_a?(self.class) && id.to_s == other.id.to_s
|
|
24
|
+
end
|
|
25
|
+
alias eql? ==
|
|
26
|
+
|
|
27
|
+
def hash
|
|
28
|
+
[self.class, id.to_s].hash
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def validate_id!(val)
|
|
34
|
+
raise ArgumentError, "id cannot be blank" if blank?(val)
|
|
35
|
+
raise ArgumentError, "id must be a String or URI" unless string_or_uri?(val)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def blank?(val)
|
|
39
|
+
val.nil? || (val.respond_to?(:to_s) && val.to_s.strip.empty?)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def string_or_uri?(val)
|
|
43
|
+
val.is_a?(String) || val.is_a?(URI::Generic)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module StructuredData
|
|
6
|
+
class Serializer
|
|
7
|
+
class << self
|
|
8
|
+
def dump(target, pretty: false)
|
|
9
|
+
new(pretty: pretty).dump(target)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :pretty
|
|
14
|
+
|
|
15
|
+
def initialize(pretty: false)
|
|
16
|
+
@pretty = pretty
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def dump(target, pretty: @pretty)
|
|
20
|
+
hash = to_hash(target)
|
|
21
|
+
json = pretty ? JSON.pretty_generate(hash) : JSON.generate(hash)
|
|
22
|
+
escape_html_script(json)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def to_hash(target)
|
|
28
|
+
if target.is_a?(Hash)
|
|
29
|
+
target
|
|
30
|
+
elsif target.respond_to?(:to_h)
|
|
31
|
+
target.to_h
|
|
32
|
+
else
|
|
33
|
+
raise ArgumentError, "Target cannot be serialized to Hash: #{target.inspect}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def escape_html_script(json)
|
|
38
|
+
json.gsub(%r{</script}i) { "<\\/script" }
|
|
39
|
+
.gsub("<!--") { "<\\!--" }
|
|
40
|
+
.gsub("\u2028") { "\\u2028" }
|
|
41
|
+
.gsub("\u2029") { "\\u2029" }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "diagnostic"
|
|
4
|
+
|
|
5
|
+
module StructuredData
|
|
6
|
+
class Validator
|
|
7
|
+
class Context
|
|
8
|
+
attr_reader :mode, :diagnostics
|
|
9
|
+
|
|
10
|
+
def initialize(mode, diagnostics)
|
|
11
|
+
@mode = mode
|
|
12
|
+
@diagnostics = diagnostics
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def severity
|
|
16
|
+
mode == :strict ? :error : :warning
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add(code:, severity:, path:, message:, suggestion: nil)
|
|
20
|
+
diagnostics << Diagnostic.new(
|
|
21
|
+
code: code,
|
|
22
|
+
severity: severity,
|
|
23
|
+
path: path,
|
|
24
|
+
message: message,
|
|
25
|
+
suggestion: suggestion
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StructuredData
|
|
4
|
+
class Validator
|
|
5
|
+
class Diagnostic
|
|
6
|
+
attr_reader :code, :severity, :path, :message, :suggestion
|
|
7
|
+
|
|
8
|
+
def initialize(code:, severity:, path:, message:, suggestion: nil)
|
|
9
|
+
@code = code
|
|
10
|
+
@severity = severity
|
|
11
|
+
@path = path
|
|
12
|
+
@message = message
|
|
13
|
+
@suggestion = suggestion
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def error?
|
|
17
|
+
severity == :error
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def warning?
|
|
21
|
+
severity == :warning
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
code: code,
|
|
27
|
+
severity: severity,
|
|
28
|
+
path: path,
|
|
29
|
+
message: message,
|
|
30
|
+
suggestion: suggestion
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ==(other)
|
|
35
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
36
|
+
end
|
|
37
|
+
alias eql? ==
|
|
38
|
+
|
|
39
|
+
def hash
|
|
40
|
+
[self.class, to_h].hash
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
Diagnostic = Validator::Diagnostic
|
|
45
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
5
|
+
module StructuredData
|
|
6
|
+
class Validator
|
|
7
|
+
class NodeValidator
|
|
8
|
+
attr_reader :vocabulary, :range_checker
|
|
9
|
+
|
|
10
|
+
def initialize(vocabulary, range_checker)
|
|
11
|
+
@vocabulary = vocabulary
|
|
12
|
+
@range_checker = range_checker
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def validate(node, path, context)
|
|
16
|
+
validate_node_type(node, path, context)
|
|
17
|
+
|
|
18
|
+
node.attributes.each do |prop, value|
|
|
19
|
+
validate_attribute(node, prop, value, path, context)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate_node_type(node, path, context)
|
|
26
|
+
if (replacing = vocabulary.superseded_by(node.type))
|
|
27
|
+
context.add(
|
|
28
|
+
code: :superseded_term, severity: context.severity,
|
|
29
|
+
path: "#{path}.@type", message: "Type '#{node.type}' is superseded by '#{replacing}'",
|
|
30
|
+
suggestion: replacing
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
return if vocabulary.type_defined?(node.type) || replacing
|
|
35
|
+
|
|
36
|
+
context.add(
|
|
37
|
+
code: :unknown_type, severity: :error,
|
|
38
|
+
path: "#{path}.@type", message: "Unknown type '#{node.type}'",
|
|
39
|
+
suggestion: suggest_type(node.type)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_attribute(node, prop, value, path, context)
|
|
44
|
+
validate_property_name(node, prop, path, context)
|
|
45
|
+
return unless vocabulary.property_defined?(prop)
|
|
46
|
+
|
|
47
|
+
validate_property_domain(node, prop, path, context)
|
|
48
|
+
validate_property_range(prop, value, path, context)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_property_name(node, prop, path, context)
|
|
52
|
+
if (replacing = vocabulary.superseded_by(prop))
|
|
53
|
+
context.add(
|
|
54
|
+
code: :superseded_term, severity: context.severity,
|
|
55
|
+
path: "#{path}.#{prop}", message: "Property '#{prop}' is superseded by '#{replacing}'",
|
|
56
|
+
suggestion: replacing
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
return if vocabulary.property_defined?(prop) || replacing
|
|
61
|
+
|
|
62
|
+
context.add(
|
|
63
|
+
code: :unknown_property, severity: :error,
|
|
64
|
+
path: "#{path}.#{prop}", message: "Unknown property '#{prop}' for type '#{node.type}'",
|
|
65
|
+
suggestion: suggest_property(prop)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_property_domain(node, prop, path, context)
|
|
70
|
+
return if vocabulary.property_allowed_for?(node.type, prop)
|
|
71
|
+
|
|
72
|
+
context.add(
|
|
73
|
+
code: :domain_mismatch, severity: context.severity,
|
|
74
|
+
path: "#{path}.#{prop}",
|
|
75
|
+
message: "Property '#{prop}' is not allowed for type '#{node.type}' or its ancestors"
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_property_range(prop, value, path, context)
|
|
80
|
+
ranges = vocabulary.expected_ranges(prop)
|
|
81
|
+
case value
|
|
82
|
+
when Array
|
|
83
|
+
validate_array_range(prop, value, ranges, path, context)
|
|
84
|
+
when List
|
|
85
|
+
validate_list_range(prop, value, ranges, path, context)
|
|
86
|
+
when Node
|
|
87
|
+
validate_single_range(prop, value, ranges, "#{path}.#{prop}", context)
|
|
88
|
+
validate(value, "#{path}.#{prop}", context)
|
|
89
|
+
else
|
|
90
|
+
validate_single_range(prop, value, ranges, "#{path}.#{prop}", context)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_array_range(prop, array, ranges, path, context)
|
|
95
|
+
array.each_with_index do |item, idx|
|
|
96
|
+
item_path = "#{path}.#{prop}[#{idx}]"
|
|
97
|
+
validate_single_range(prop, item, ranges, item_path, context)
|
|
98
|
+
validate(item, item_path, context) if item.is_a?(Node)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_list_range(prop, list, ranges, path, context)
|
|
103
|
+
list.items.each_with_index do |item, idx|
|
|
104
|
+
item_path = "#{path}.#{prop}.@list[#{idx}]"
|
|
105
|
+
validate_single_range(prop, item, ranges, item_path, context)
|
|
106
|
+
validate(item, item_path, context) if item.is_a?(Node)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_single_range(prop, value, ranges, path, context)
|
|
111
|
+
return if range_checker.matches?(value, ranges)
|
|
112
|
+
|
|
113
|
+
context.add(
|
|
114
|
+
code: :range_mismatch, severity: context.severity,
|
|
115
|
+
path: path,
|
|
116
|
+
message: "Value for property '#{prop}' does not match expected range(s): #{ranges.join(', ')}"
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def suggest_type(type)
|
|
121
|
+
@type_checker ||= DidYouMean::SpellChecker.new(dictionary: vocabulary.all_types)
|
|
122
|
+
@type_checker.correct(type.to_s).first
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def suggest_property(prop)
|
|
126
|
+
@property_checker ||= DidYouMean::SpellChecker.new(dictionary: vocabulary.all_properties)
|
|
127
|
+
@property_checker.correct(prop.to_s).first
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|