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,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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails/registry"
4
+ require_relative "rails/renderer"
5
+ require_relative "rails/helper"
6
+ require_relative "rails/railtie"
@@ -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