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,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,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
|