json-ld 0.1.3 → 0.1.4
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.
- data/History.markdown +3 -0
- data/VERSION +1 -1
- data/bin/jsonld +134 -0
- data/lib/json/ld/api.rb +4 -4
- data/lib/json/ld/frame.rb +2 -1
- data/lib/json/ld/from_rdf.rb +1 -1
- data/spec/api_spec.rb +66 -0
- data/spec/compact_spec.rb +420 -0
- data/spec/evaluation_context_spec.rb +1039 -0
- data/spec/expand_spec.rb +625 -0
- data/spec/format_spec.rb +71 -0
- data/spec/frame_spec.rb +542 -0
- data/spec/from_rdf_spec.rb +316 -0
- data/spec/matchers.rb +67 -0
- data/spec/reader_spec.rb +79 -0
- data/spec/spec_helper.rb +56 -0
- data/spec/suite_helper.rb +184 -0
- data/spec/suite_spec.rb +104 -0
- data/spec/support/extensions.rb +10 -0
- data/spec/test-files/test-1-automatic.json +10 -0
- data/spec/test-files/test-1-compacted.json +10 -0
- data/spec/test-files/test-1-context.json +7 -0
- data/spec/test-files/test-1-expanded.json +5 -0
- data/spec/test-files/test-1-input.json +10 -0
- data/spec/test-files/test-1-normalized.json +8 -0
- data/spec/test-files/test-1-rdf.ttl +7 -0
- data/spec/test-files/test-2-automatic.json +27 -0
- data/spec/test-files/test-2-compacted.json +20 -0
- data/spec/test-files/test-2-context.json +7 -0
- data/spec/test-files/test-2-expanded.json +16 -0
- data/spec/test-files/test-2-input.json +20 -0
- data/spec/test-files/test-2-normalized.json +32 -0
- data/spec/test-files/test-2-rdf.ttl +14 -0
- data/spec/test-files/test-3-compacted.json +11 -0
- data/spec/test-files/test-3-context.json +8 -0
- data/spec/test-files/test-3-expanded.json +10 -0
- data/spec/test-files/test-3-input.json +11 -0
- data/spec/test-files/test-3-normalized.json +13 -0
- data/spec/test-files/test-3-rdf.ttl +7 -0
- data/spec/test-files/test-4-automatic.json +10 -0
- data/spec/test-files/test-4-compacted.json +10 -0
- data/spec/test-files/test-4-context.json +7 -0
- data/spec/test-files/test-4-expanded.json +6 -0
- data/spec/test-files/test-4-input.json +10 -0
- data/spec/test-files/test-4-rdf.ttl +5 -0
- data/spec/test-files/test-5-automatic.json +13 -0
- data/spec/test-files/test-5-compacted.json +13 -0
- data/spec/test-files/test-5-context.json +7 -0
- data/spec/test-files/test-5-expanded.json +9 -0
- data/spec/test-files/test-5-input.json +13 -0
- data/spec/test-files/test-5-rdf.ttl +6 -0
- data/spec/test-files/test-6-automatic.json +10 -0
- data/spec/test-files/test-6-compacted.json +10 -0
- data/spec/test-files/test-6-context.json +7 -0
- data/spec/test-files/test-6-expanded.json +10 -0
- data/spec/test-files/test-6-input.json +10 -0
- data/spec/test-files/test-6-rdf.ttl +5 -0
- data/spec/test-files/test-7-automatic.json +20 -0
- data/spec/test-files/test-7-compacted.json +23 -0
- data/spec/test-files/test-7-context.json +4 -0
- data/spec/test-files/test-7-expanded.json +20 -0
- data/spec/test-files/test-7-input.json +23 -0
- data/spec/test-files/test-7-rdf.ttl +13 -0
- data/spec/test-files/test-8-automatic.json +1 -0
- data/spec/test-files/test-8-compacted.json +34 -0
- data/spec/test-files/test-8-context.json +11 -0
- data/spec/test-files/test-8-expanded.json +24 -0
- data/spec/test-files/test-8-frame.json +18 -0
- data/spec/test-files/test-8-framed.json +29 -0
- data/spec/test-files/test-8-input.json +30 -0
- data/spec/test-files/test-8-rdf.ttl +15 -0
- data/spec/test-files/test-9-compacted.json +20 -0
- data/spec/test-files/test-9-context.json +13 -0
- data/spec/test-files/test-9-expanded.json +14 -0
- data/spec/test-files/test-9-input.json +12 -0
- data/spec/to_rdf_spec.rb +640 -0
- data/spec/writer_spec.rb +161 -0
- metadata +150 -22
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
2
|
+
$:.unshift File.dirname(__FILE__)
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require 'rspec'
|
6
|
+
require 'rdf'
|
7
|
+
require 'rdf/isomorphic'
|
8
|
+
require 'json/ld'
|
9
|
+
require 'rdf/nquads'
|
10
|
+
require 'rdf/turtle'
|
11
|
+
require 'rdf/trig'
|
12
|
+
require 'rdf/spec'
|
13
|
+
require 'rdf/spec/matchers'
|
14
|
+
require 'yaml'
|
15
|
+
require 'open-uri/cached'
|
16
|
+
require 'matchers'
|
17
|
+
|
18
|
+
JSON_STATE = JSON::State.new(
|
19
|
+
:indent => " ",
|
20
|
+
:space => " ",
|
21
|
+
:space_before => "",
|
22
|
+
:object_nl => "\n",
|
23
|
+
:array_nl => "\n"
|
24
|
+
)
|
25
|
+
|
26
|
+
# Create and maintain a cache of downloaded URIs
|
27
|
+
URI_CACHE = File.expand_path(File.join(File.dirname(__FILE__), "uri-cache"))
|
28
|
+
Dir.mkdir(URI_CACHE) unless File.directory?(URI_CACHE)
|
29
|
+
OpenURI::Cache.class_eval { @cache_path = URI_CACHE }
|
30
|
+
|
31
|
+
::RSpec.configure do |c|
|
32
|
+
c.filter_run :focus => true
|
33
|
+
c.run_all_when_everything_filtered = true
|
34
|
+
c.exclusion_filter = {
|
35
|
+
:ruby => lambda { |version| !(RUBY_VERSION.to_s =~ /^#{version.to_s}/) },
|
36
|
+
}
|
37
|
+
c.include(RDF::Spec::Matchers)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Heuristically detect the input stream
|
41
|
+
def detect_format(stream)
|
42
|
+
# Got to look into the file to see
|
43
|
+
if stream.is_a?(IO) || stream.is_a?(StringIO)
|
44
|
+
stream.rewind
|
45
|
+
string = stream.read(1000)
|
46
|
+
stream.rewind
|
47
|
+
else
|
48
|
+
string = stream.to_s
|
49
|
+
end
|
50
|
+
case string
|
51
|
+
when /<html/i then RDF::RDFa::Reader
|
52
|
+
when /\{\s*\"@\"/i then JSON::LD::Reader
|
53
|
+
else RDF::Turtle::Reader
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
@@ -0,0 +1,184 @@
|
|
1
|
+
# Spira class for manipulating test-manifest style test suites.
|
2
|
+
# Used for SWAP tests
|
3
|
+
require 'spira'
|
4
|
+
require 'json/ld'
|
5
|
+
require 'open-uri'
|
6
|
+
require 'support/extensions'
|
7
|
+
|
8
|
+
# For now, override RDF::Utils::File.open_file to look for the file locally before attempting to retrieve it
|
9
|
+
module RDF::Util
|
10
|
+
module File
|
11
|
+
REMOTE_PATH = "http://json-ld.org/test-suite/"
|
12
|
+
LOCAL_PATH = ::File.expand_path("../json-ld.org/test-suite", __FILE__) + '/'
|
13
|
+
|
14
|
+
##
|
15
|
+
# Override to use Patron for http and https, Kernel.open otherwise.
|
16
|
+
#
|
17
|
+
# @param [String] filename_or_url to open
|
18
|
+
# @param [Hash{Symbol => Object}] options
|
19
|
+
# @option options [Array, String] :headers
|
20
|
+
# HTTP Request headers.
|
21
|
+
# @return [IO] File stream
|
22
|
+
# @yield [IO] File stream
|
23
|
+
def self.open_file(filename_or_url, options = {}, &block)
|
24
|
+
case filename_or_url.to_s
|
25
|
+
when /^file:/
|
26
|
+
path = filename_or_url[5..-1]
|
27
|
+
Kernel.open(path.to_s, &block)
|
28
|
+
when /^#{REMOTE_PATH}/
|
29
|
+
#puts "attempt to open #{filename_or_url} locally"
|
30
|
+
if response = ::File.open(filename_or_url.to_s.sub(REMOTE_PATH, LOCAL_PATH))
|
31
|
+
#puts "use #{filename_or_url} locally"
|
32
|
+
case filename_or_url.to_s
|
33
|
+
when /\.jsonld$/
|
34
|
+
def response.content_type; 'application/ld+json'; end
|
35
|
+
when /\.sparql$/
|
36
|
+
def response.content_type; 'application/sparql-query'; end
|
37
|
+
end
|
38
|
+
|
39
|
+
if block_given?
|
40
|
+
begin
|
41
|
+
yield response
|
42
|
+
ensure
|
43
|
+
response.close
|
44
|
+
end
|
45
|
+
else
|
46
|
+
response
|
47
|
+
end
|
48
|
+
else
|
49
|
+
Kernel.open(filename_or_url.to_s, &block)
|
50
|
+
end
|
51
|
+
else
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
module Fixtures
|
58
|
+
module JSONLDTest
|
59
|
+
SUITE = RDF::URI("http://json-ld.org/test-suite/")
|
60
|
+
class Test < RDF::Vocabulary("http://www.w3.org/2006/03/test-description#"); end
|
61
|
+
class Jld < RDF::Vocabulary("http://json-ld.org/test-suite/vocab#"); end
|
62
|
+
|
63
|
+
class Manifest < Spira::Base
|
64
|
+
type Jld.Manifest
|
65
|
+
property :name, :predicate => RDF::DC.title, :type => XSD.string
|
66
|
+
property :comment, :predicate => RDF::RDFS.comment, :type => XSD.string
|
67
|
+
property :sequence, :predicate => Jld.sequence
|
68
|
+
|
69
|
+
def entries
|
70
|
+
@entries ||= begin
|
71
|
+
repo = self.class.repository
|
72
|
+
RDF::List.new(sequence, repo).map do |entry|
|
73
|
+
results = repo.query(:subject => entry, :predicate => RDF.type)
|
74
|
+
entry_types = results.map(&:object)
|
75
|
+
|
76
|
+
# Load entry if it is not in repo
|
77
|
+
if entry_types.empty?
|
78
|
+
repo.load(entry, :format => :jsonld)
|
79
|
+
entry_types = repo.query(:subject => entry, :predicate => RDF.type).map(&:object)
|
80
|
+
end
|
81
|
+
|
82
|
+
case
|
83
|
+
when entry_types.include?(Jld.Manifest) then entry.as(Manifest)
|
84
|
+
when entry_types.include?(Jld.CompactTest) then entry.as(CompactTest)
|
85
|
+
when entry_types.include?(Jld.ExpandTest) then entry.as(ExpandTest)
|
86
|
+
when entry_types.include?(Jld.FrameTest) then entry.as(FrameTest)
|
87
|
+
when entry_types.include?(Jld.NormalizeTest) then entry.as(NormalizeTest)
|
88
|
+
when entry_types.include?(Jld.ToRDFTest) then entry.as(ToRDFTest)
|
89
|
+
when entry_types.include?(Jld.FromRDFTest) then entry.as(FromRDFTest)
|
90
|
+
when entry_types.include?(Test.TestCase) then entry.as(Entry)
|
91
|
+
else raise "Unexpected entry type: #{entry_types.inspect}"
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def inspect
|
98
|
+
"[#{self.class.to_s} " + %w(
|
99
|
+
subject
|
100
|
+
name
|
101
|
+
).map {|a| v = self.send(a); "#{a}='#{v}'" if v}.compact.join(", ") +
|
102
|
+
", entries=#{entries.length}" +
|
103
|
+
"]"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
class Entry
|
108
|
+
attr_accessor :debug
|
109
|
+
include Spira::Resource
|
110
|
+
type Test.TestCase
|
111
|
+
|
112
|
+
property :name, :predicate => RDF::DC.title, :type => XSD.string
|
113
|
+
property :purpose, :predicate => Test.purpose, :type => XSD.string
|
114
|
+
property :expected, :predicate => Test.expectedResults
|
115
|
+
property :inputDocument, :predicate => Test.informationResourceInput
|
116
|
+
property :resultDocument, :predicate => Test.informationResourceResults
|
117
|
+
property :extraDocument, :predicate => Test.input
|
118
|
+
|
119
|
+
def information; name; end
|
120
|
+
|
121
|
+
def input
|
122
|
+
RDF::Util::File.open_file(self.inputDocument)
|
123
|
+
end
|
124
|
+
|
125
|
+
def extra
|
126
|
+
RDF::Util::File.open_file(self.extraDocument)
|
127
|
+
end
|
128
|
+
|
129
|
+
def expect
|
130
|
+
RDF::Util::File.open_file(self.resultDocument)
|
131
|
+
end
|
132
|
+
|
133
|
+
def base_uri
|
134
|
+
inputDocument.to_s
|
135
|
+
end
|
136
|
+
|
137
|
+
def trace
|
138
|
+
@debug.to_a.join("\n")
|
139
|
+
end
|
140
|
+
|
141
|
+
def inspect
|
142
|
+
"[#{self.class.to_s} " + %w(
|
143
|
+
subject
|
144
|
+
name
|
145
|
+
inputDocument
|
146
|
+
resultDocument
|
147
|
+
extraDocument
|
148
|
+
).map {|a| v = self.send(a); "#{a}='#{v}'" if v}.compact.join(", ") +
|
149
|
+
"]"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
class CompactTest < Entry
|
154
|
+
type Jld.CompactTest
|
155
|
+
end
|
156
|
+
|
157
|
+
class ExpandTest < Entry
|
158
|
+
type Jld.ExpandTest
|
159
|
+
end
|
160
|
+
|
161
|
+
class FrameTest < Entry
|
162
|
+
type Jld.FameTest
|
163
|
+
end
|
164
|
+
|
165
|
+
class NormalizeTest < Entry
|
166
|
+
type Jld.NormalizeTest
|
167
|
+
end
|
168
|
+
|
169
|
+
class FromRDFTest < Entry
|
170
|
+
type Jld.FromRDFTest
|
171
|
+
end
|
172
|
+
|
173
|
+
class ToRDFTest < Entry
|
174
|
+
type Jld.ToRDFTest
|
175
|
+
|
176
|
+
def quads
|
177
|
+
RDF::Util::File.open_file(self.expected)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
repo = RDF::Repository.load(SUITE.join("manifest.jsonld"), :format => :jsonld)
|
182
|
+
Spira.add_repository! :default, repo
|
183
|
+
end
|
184
|
+
end
|
data/spec/suite_spec.rb
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
$:.unshift "."
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
describe JSON::LD do
|
6
|
+
describe "test suite" do
|
7
|
+
require 'suite_helper'
|
8
|
+
|
9
|
+
m = Fixtures::JSONLDTest::Manifest.each.to_a.first
|
10
|
+
describe m.name do
|
11
|
+
m.entries.each do |m2|
|
12
|
+
describe m2.name do
|
13
|
+
m2.entries.each do |t|
|
14
|
+
next if t.is_a?(Fixtures::JSONLDTest::NormalizeTest)
|
15
|
+
specify "#{File.basename(t.inputDocument.to_s)}: #{t.name}" do
|
16
|
+
begin
|
17
|
+
t.debug = ["test: #{t.inspect}", "source: #{t.input.read}"]
|
18
|
+
case t
|
19
|
+
when Fixtures::JSONLDTest::CompactTest
|
20
|
+
t.debug << "context: #{t.extra.read}" if t.extraDocument
|
21
|
+
result = JSON::LD::API.compact(t.input, t.extra, nil,
|
22
|
+
:base => t.base_uri,
|
23
|
+
:debug => t.debug)
|
24
|
+
expected = JSON.load(t.expect)
|
25
|
+
result.should produce(expected, t.debug)
|
26
|
+
when Fixtures::JSONLDTest::ExpandTest
|
27
|
+
t.debug << "context: #{t.extra.read}" if t.extraDocument
|
28
|
+
result = JSON::LD::API.expand(t.input, nil, nil,
|
29
|
+
:base => t.base_uri,
|
30
|
+
:debug => t.debug)
|
31
|
+
expected = JSON.load(t.expect)
|
32
|
+
result.should produce(expected, t.debug)
|
33
|
+
when Fixtures::JSONLDTest::FrameTest
|
34
|
+
t.debug << "frame: #{t.extra.read}" if t.extraDocument
|
35
|
+
result = JSON::LD::API.frame(t.input, t.extra, nil,
|
36
|
+
:base => t.inputDocument,
|
37
|
+
:debug => t.debug)
|
38
|
+
expected = JSON.load(t.expect)
|
39
|
+
result.should produce(expected, t.debug)
|
40
|
+
when Fixtures::JSONLDTest::FromRDFTest
|
41
|
+
repo = RDF::Repository.load(t.inputDocument)
|
42
|
+
result = JSON::LD::API.fromRDF(repo.each_statement.to_a, nil,
|
43
|
+
:debug => t.debug)
|
44
|
+
expected = JSON.load(t.expect)
|
45
|
+
result.should produce(expected, t.debug)
|
46
|
+
when Fixtures::JSONLDTest::ToRDFTest
|
47
|
+
quads = []
|
48
|
+
JSON::LD::API.toRDF(t.input, nil, nil,
|
49
|
+
:base => t.inputDocument,
|
50
|
+
:debug => t.debug) do |statement|
|
51
|
+
quads << to_quad(statement)
|
52
|
+
end
|
53
|
+
|
54
|
+
quads.sort.join("").should produce(t.expect.read, t.debug)
|
55
|
+
else
|
56
|
+
pending("unkown test type #{t.inspect}")
|
57
|
+
end
|
58
|
+
rescue JSON::LD::ProcessingError => e
|
59
|
+
fail("Processing error: #{e.message}")
|
60
|
+
rescue JSON::LD::InvalidContext => e
|
61
|
+
fail("Invalid Context: #{e.message}")
|
62
|
+
rescue JSON::LD::InvalidFrame => e
|
63
|
+
fail("Invalid Frame: #{e.message}")
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Don't use NQuads writer so that we don't escape Unicode
|
73
|
+
def to_quad(thing)
|
74
|
+
case thing
|
75
|
+
when RDF::URI
|
76
|
+
"<#{escaped(thing.to_s)}>"
|
77
|
+
when RDF::Node
|
78
|
+
escaped(thing.to_s)
|
79
|
+
when RDF::Literal::Double
|
80
|
+
quoted("%1.15e" % thing.value) + "^^<#{RDF::XSD.double}>"
|
81
|
+
when RDF::Literal
|
82
|
+
quoted(escaped(thing.value)) +
|
83
|
+
(thing.datatype? ? "^^<#{thing.datatype}>" : "") +
|
84
|
+
(thing.language? ? "@#{thing.language}" : "")
|
85
|
+
when RDF::Statement
|
86
|
+
thing.to_quad.map {|r| to_quad(r)}.compact.join(" ") + " .\n"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
##
|
91
|
+
# @param [String] string
|
92
|
+
# @return [String]
|
93
|
+
def quoted(string)
|
94
|
+
"\"#{string}\""
|
95
|
+
end
|
96
|
+
|
97
|
+
##
|
98
|
+
# @param [String] string
|
99
|
+
# @return [String]
|
100
|
+
def escaped(string)
|
101
|
+
string.gsub('\\', '\\\\').gsub("\t", '\\t').
|
102
|
+
gsub("\n", '\\n').gsub("\r", '\\r').gsub('"', '\\"')
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
{
|
2
|
+
"@context": {
|
3
|
+
"avatar": "http://xmlns.com/foaf/0.1/avatar",
|
4
|
+
"homepage": "http://xmlns.com/foaf/0.1/homepage",
|
5
|
+
"name": "http://xmlns.com/foaf/0.1/name"
|
6
|
+
},
|
7
|
+
"avatar": "http://twitter.com/account/profile_image/manusporny",
|
8
|
+
"homepage": "http://manu.sporny.org/",
|
9
|
+
"name": "Manu Sporny"
|
10
|
+
}
|
@@ -0,0 +1,10 @@
|
|
1
|
+
{
|
2
|
+
"@context": {
|
3
|
+
"avatar": "http://xmlns.com/foaf/0.1/avatar",
|
4
|
+
"homepage": "http://xmlns.com/foaf/0.1/homepage",
|
5
|
+
"name": "http://xmlns.com/foaf/0.1/name"
|
6
|
+
},
|
7
|
+
"avatar": "http://twitter.com/account/profile_image/manusporny",
|
8
|
+
"homepage": "http://manu.sporny.org/",
|
9
|
+
"name": "Manu Sporny"
|
10
|
+
}
|
@@ -0,0 +1,10 @@
|
|
1
|
+
{
|
2
|
+
"@context": {
|
3
|
+
"name": "http://xmlns.com/foaf/0.1/name",
|
4
|
+
"homepage": "http://xmlns.com/foaf/0.1/homepage",
|
5
|
+
"avatar": "http://xmlns.com/foaf/0.1/avatar"
|
6
|
+
},
|
7
|
+
"name": "Manu Sporny",
|
8
|
+
"homepage": "http://manu.sporny.org/",
|
9
|
+
"avatar": "http://twitter.com/account/profile_image/manusporny"
|
10
|
+
}
|
@@ -0,0 +1,7 @@
|
|
1
|
+
@prefix avatar: <http://xmlns.com/foaf/0.1/avatar> .
|
2
|
+
@prefix homepage: <http://xmlns.com/foaf/0.1/homepage> .
|
3
|
+
@prefix name: <http://xmlns.com/foaf/0.1/name> .
|
4
|
+
|
5
|
+
[ avatar: "http://twitter.com/account/profile_image/manusporny";
|
6
|
+
homepage: "http://manu.sporny.org/";
|
7
|
+
name: "Manu Sporny"] .
|
@@ -0,0 +1,27 @@
|
|
1
|
+
{
|
2
|
+
"@context": [
|
3
|
+
{
|
4
|
+
"dc": "http://purl.org/dc/elements/1.1/",
|
5
|
+
"ex": "http://example.org/vocab#"
|
6
|
+
},
|
7
|
+
{
|
8
|
+
"ex:contains": {
|
9
|
+
"@coerce": "@id"
|
10
|
+
}
|
11
|
+
}
|
12
|
+
],
|
13
|
+
"@id": "http://example.org/library",
|
14
|
+
"@type": "ex:Library",
|
15
|
+
"ex:contains": {
|
16
|
+
"@id": "http://example.org/library/the-republic",
|
17
|
+
"@type": "ex:Book",
|
18
|
+
"dc:creator": "Plato",
|
19
|
+
"dc:title": "The Republic",
|
20
|
+
"ex:contains": {
|
21
|
+
"@id": "http://example.org/library/the-republic#introduction",
|
22
|
+
"@type": "ex:Chapter",
|
23
|
+
"dc:description": "An introductory chapter on The Republic.",
|
24
|
+
"dc:title": "The Introduction"
|
25
|
+
}
|
26
|
+
}
|
27
|
+
}
|