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