orthor 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Robotic Foo
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,109 @@
1
+ # Orthor Client
2
+
3
+ The Orthor gem is used to fetch and display your content from orthor.com
4
+
5
+ ## Installation
6
+
7
+ Is easy
8
+
9
+ gem install orthor
10
+
11
+ ## Configuration
12
+
13
+ Add the below to a file and require it:
14
+
15
+ Orthor.setup do
16
+ account "orthor" # your orthor account id
17
+ caching :memory, 600 # cache content in memory for 10 minutes
18
+ end
19
+
20
+ You can specify any Moneta cache store class name, e.g. basic_file, memcache etc. The (optional) second argument is cache expiry in seconds, you can provide any initialize options you want as the 3rd arg, e.g.:
21
+
22
+ Orthor.setup do
23
+ account "bob"
24
+ caching :basic_file, 300, :path => "tmp/"
25
+ end
26
+
27
+ ## Usage
28
+
29
+ The first argument to all these methods is the orthor-id of your piece of content/category etc. The second argument is the name of a template (see below) to use to render the returned content.
30
+
31
+ If you do not provide a template_name to render your content with, you will get the parsed JSON back.
32
+
33
+ ### Content
34
+
35
+ Orthor.content("id", :template_name)
36
+
37
+ ### Queries
38
+
39
+ Orthor.query("id", :template_name)
40
+
41
+ ### Categories
42
+
43
+ Orthor.category("id", :template_name)
44
+
45
+ ### Feeds
46
+
47
+ Orthor.feed("id")
48
+
49
+ ## Templates
50
+
51
+ Orthor returns all of your content in JSON so to make the job of translating JSON -> HTML, we provide some templating help:
52
+
53
+ Orthor::Templates.define do
54
+ template :basic_content, %(<div>{{Content}}</div>)
55
+
56
+ template :blog_entry, %(
57
+ <div class="blog-entry">
58
+ <h2>{{Title}}</h2>
59
+ <div class="blog-content">{{Wysiwyg}}</div>
60
+ </div>)
61
+ template :blog_entry_brief, %(
62
+ <div class="blog-entry">
63
+ <h2><a href="{{URL}}">{{Title}}</a></h2>
64
+ <p>{{Published on}}</p>
65
+ <div class="blog-content">{{Wysiwyg.blurb}}</div>
66
+ </div>)
67
+
68
+ template :user_manual, %(
69
+ <div class="user-manual-entry">
70
+ <h2>{{Title}}</h2>
71
+ <p class="last-updated">Last updated: {{Updated on}}</p>
72
+ <div class="content">{{Wysiwyg}}</div>
73
+ </div>)
74
+ end
75
+
76
+ Now any call you make to Orthor.content/category/query can be given a 2nd argument of a template name and rather than receiving parsed JSON, you will be returned a HTML string.
77
+
78
+ ### Default template tags
79
+
80
+ These tags are available to all content:
81
+
82
+ {{id}} - the orthor id of your content item
83
+ {{Title}} - your content title
84
+ {{Created on}} - the date your content was created
85
+ {{Updated on}} - the date your content was last updated
86
+ {{Author}} - a hash of the original authors details
87
+ {{Updater}} - a hash of the updater details
88
+ {{Published on}} - the date your content was published
89
+ {{URL}} - the URL for your content as defined in Orthor, or auto generated as /:template-prefix/category-id/content-id
90
+ {{template}} - the name of the template this content was created from
91
+ {{category}} - a hash of this items category details
92
+
93
+ Other attributes that will be present on a per item specific basis are are the names of your template elements, e.g.
94
+
95
+ {{Content}}
96
+ {{Featured News Item}}
97
+ {{Supporting Image}}
98
+ {{Markdown body}}
99
+
100
+ ## Questions? Comments?
101
+
102
+ anthony@orthor.com
103
+
104
+ ## Examples
105
+
106
+ [Orthor demo site](http://github.com/anthony/orthor-demo)
107
+
108
+ Copyright (c) 2009 Robotic Foo, released under the MIT license
109
+
@@ -0,0 +1,90 @@
1
+ require 'moneta'
2
+ require 'json'
3
+
4
+ require 'orthor/templates'
5
+
6
+ class Orthor
7
+ class << self
8
+ attr_accessor :cache, :cache_expiry, :account_id
9
+ end
10
+
11
+ def self.content(id, template = nil)
12
+ Templates.render(get(:content_items, id), template)
13
+ end
14
+
15
+ def self.query(id, template = nil)
16
+ Templates.render(get(:queries, id), template)
17
+ end
18
+
19
+ def self.category(id, template = nil)
20
+ Templates.render(get(:categories, id), template)
21
+ end
22
+
23
+ def self.feed(id)
24
+ get(:feeds, id)
25
+ end
26
+
27
+ def self.render(items, template)
28
+ Templates.render(items, template)
29
+ end
30
+
31
+ def self.caching(file, expiry = nil, options = {})
32
+ expiry, options = nil, expiry if expiry.is_a?(Hash)
33
+
34
+ klass = file.to_s.split('_').collect { |e| e.capitalize }.join
35
+ Moneta.autoload(klass.to_sym, "moneta/#{file}")
36
+ self.cache = Moneta.const_get(klass).new(options)
37
+ self.cache_expiry = expiry
38
+ end
39
+
40
+ def self.account(id)
41
+ self.account_id = id
42
+ end
43
+
44
+ def self.setup(&block)
45
+ raise ArgumentError unless block_given?
46
+ self.cache = false # default
47
+
48
+ class_eval &block
49
+ end
50
+
51
+ private
52
+ def self.get(type, id)
53
+ raise "No orthor account given... Please add one to your Orthor setup block" unless account_id
54
+
55
+ begin
56
+ suffix = type == :feeds ? "rss" : "json"
57
+ key = "#{id}-#{type}"
58
+ url = "http://content.orthor.com/#{account_id}/#{type}/#{id}.#{suffix}"
59
+
60
+ resp = cache ? get_cached_content(key, url) : get_response(url)
61
+
62
+ return resp unless suffix == "json"
63
+
64
+ JSON.parse(resp)
65
+ rescue => e
66
+ return ""
67
+ end
68
+ end
69
+
70
+ def self.get_cached_content(id, url)
71
+ content = cache[id]
72
+ if content.nil? || content.empty?
73
+ content = get_response(url)
74
+
75
+ options = {}
76
+ options[:expires_in] = cache_expiry if cache_expiry
77
+ cache.store(id, content, options) unless content.empty?
78
+ end
79
+ content
80
+ end
81
+
82
+ def self.get_response(uri)
83
+ r = Net::HTTP.get_response(URI.parse(uri))
84
+ r.code.to_i == 200 ? r.body : ""
85
+ rescue => e
86
+ ""
87
+ end
88
+
89
+ private_class_method :get, :get_cached_content, :get_response
90
+ end
@@ -0,0 +1,89 @@
1
+ class Orthor
2
+ class Templates
3
+ class Unknown < StandardError; end
4
+
5
+ class << self
6
+ attr_accessor :templates, :mappings
7
+ end
8
+
9
+ def self.define(&blk)
10
+ self.templates, self.mappings = {}, {}
11
+ class_eval &blk
12
+ end
13
+
14
+ def self.template(name, markup)
15
+ self.templates[name.to_s] = markup
16
+ end
17
+
18
+ def self.mapping(name, map)
19
+ self.mappings[name] = map
20
+ end
21
+
22
+ def self.[](key)
23
+ self.templates ||= {}
24
+ self.templates[key.to_s]
25
+ end
26
+
27
+ def self.render(items, template_name_or_mapping)
28
+ return items unless template_name_or_mapping
29
+
30
+ items = [items] unless items.is_a?(Array)
31
+ items.inject("") do |html, item|
32
+ html += parse(item, template_name_or_mapping)
33
+ end
34
+ end
35
+
36
+ private
37
+ def self.parse(item, template_name_or_mapping)
38
+ # ensure we're dealing with an array
39
+ unless item.is_a?(Array)
40
+ if item["type"] == "category"
41
+ item = item["content"]
42
+ else
43
+ item = [item]
44
+ end
45
+ end
46
+
47
+ item.collect do |c|
48
+ markup = template_for(c, template_name_or_mapping)
49
+
50
+ c.inject(markup) do |html, (key, val)|
51
+ if val.is_a?(String) && html.include?("{{#{key}.blurb}}")
52
+ val = "#{val.to_s.gsub(/<\/?[^>]*>/, "")[0..150]}..."
53
+ key = "#{key}.blurb"
54
+
55
+ html = html.gsub("{{#{key}}}", val.to_s)
56
+ elsif val.is_a?(Hash) && html.include?("{{#{key}.")
57
+ # we've got something like Author.Email
58
+ html = val.inject(html) { |h, (k, v)| h = h.gsub("{{#{key}.#{k}}}", v.to_s) }
59
+ else
60
+ html = html.gsub("{{#{key}}}", val.to_s)
61
+ end
62
+ end
63
+ end.join("")
64
+ rescue => e
65
+ raise e if e.is_a?(Orthor::Templates::Unknown)
66
+ return ""
67
+ end
68
+
69
+ def self.template_for(item, template_name_or_mapping)
70
+ if [String, Symbol].include?(template_name_or_mapping.class)
71
+ markup = self[template_name_or_mapping]
72
+
73
+ if !markup && self.mappings[template_name_or_mapping]
74
+ # we've actually got a named mapping
75
+ markup = self[self.mappings[template_name_or_mapping][item["template"]]]
76
+ else
77
+ raise Unknown, "Could not find template #{template_name_or_mapping}" unless markup
78
+ end
79
+ end
80
+ if template_name_or_mapping.is_a?(Hash)
81
+ markup = self[template_name_or_mapping[item["template"]]]
82
+ raise Unknown, "Could not find template mapping for #{item["template"]}" unless markup
83
+ end
84
+ markup
85
+ end
86
+
87
+ private_class_method :parse, :template_for
88
+ end
89
+ end
@@ -0,0 +1,125 @@
1
+ require 'spec_helper'
2
+
3
+ describe Orthor::Templates do
4
+ before(:all) do
5
+ FakeWeb.register_uri(:get, "http://content.orthor.com/orthor/content_items/content.json",
6
+ :body => File.join(SPEC_DIR, 'resources', 'content.json'))
7
+ FakeWeb.register_uri(:get, "http://content.orthor.com/orthor/queries/query.json",
8
+ :body => File.join(SPEC_DIR, 'resources', 'query.json'))
9
+ FakeWeb.register_uri(:get, "http://content.orthor.com/orthor/categories/category.json",
10
+ :body => File.join(SPEC_DIR, 'resources', 'category.json'))
11
+ FakeWeb.register_uri(:get, "http://content.orthor.com/orthor/categories/varied.json",
12
+ :body => File.join(SPEC_DIR, 'resources', 'varied_content.json'))
13
+ FakeWeb.register_uri(:get, "http://content.orthor.com/orthor/feeds/feed.rss",
14
+ :body => File.join(SPEC_DIR, 'resources', 'feed.rss'))
15
+ FakeWeb.register_uri(:get, "http://content.orthor.com/orthor/content_items/missing.json",
16
+ :body => "")
17
+
18
+ Orthor.setup do
19
+ account "orthor"
20
+ end
21
+
22
+ Orthor::Templates.define do
23
+ template :basic, %!<h2>{{Title}}</h2><div class="content">{{Content}}</div>!
24
+ template :brief, %!<h2>{{Title}}</h2>!
25
+ template :blurb, %!<div>{{Content.blurb}}</div>!
26
+ template :user, %!<div>{{Creator.Email}}</div><div>{{Creator.First name}} {{Creator.Last name}}</div>!
27
+
28
+ mapping :listing, { "Text Block" => :brief }
29
+ end
30
+ end
31
+
32
+ describe "with no content returned" do
33
+ it "should return an empty string" do
34
+ Orthor.content("missing").should be_empty
35
+ end
36
+ end
37
+
38
+ describe "defining a set of templates" do
39
+ it "should store the defined templates" do
40
+ Orthor::Templates.templates.length.should == 4
41
+ end
42
+
43
+ it "should return templates by name" do
44
+ Orthor::Templates[:basic].should == %!<h2>{{Title}}</h2><div class="content">{{Content}}</div>!
45
+ end
46
+ end
47
+
48
+ describe "nested attributes" do
49
+ it "should support User.Email, User.First name, User.Last name" do
50
+ Orthor.content("content", :user).should == "<div>demo@orthor.com</div><div>Bob Demo</div>"
51
+ end
52
+ end
53
+
54
+ describe "a content item" do
55
+ it "should display content using the named template" do
56
+ Orthor.content("content", :basic).should == %!<h2>What is Orthor</h2><div class="content"><h2>So what is Orthor really??</h2></div>!
57
+ end
58
+
59
+ it "should respect given template name" do
60
+ Orthor.content("content", :brief).should == %!<h2>What is Orthor</h2>!
61
+ end
62
+
63
+ describe "blurb" do
64
+ it "should truncate some of the text, strip html and add ..." do
65
+ Orthor.content("content", :blurb).should == "<div>So what is Orthor really??...</div>"
66
+ end
67
+ end
68
+ end
69
+
70
+ describe "a content query" do
71
+ it "should render the template for each item" do
72
+ Orthor.query("query", :brief).should == %!<h2>User Manual updated</h2><h2>Account event tracking</h2><h2>Tutorials have been updated</h2>!
73
+ end
74
+ end
75
+
76
+ describe "a category" do
77
+ it "should render the template for each item" do
78
+ Orthor.category("category", :brief).should == %!<h2>User Manual updated</h2><h2>Account event tracking</h2><h2>Tutorials have been updated</h2><h2>Content Queries have landed in Orthor</h2>!
79
+ end
80
+ end
81
+
82
+ describe "rendering given a hash/array" do
83
+ before(:all) do
84
+ @json = Orthor.category("category")
85
+ end
86
+
87
+ it "should be able to render an array of items" do
88
+ Orthor.render(@json["content"], :brief).should == "<h2>User Manual updated</h2><h2>Account event tracking</h2><h2>Tutorials have been updated</h2><h2>Content Queries have landed in Orthor</h2>"
89
+ end
90
+
91
+ it "should be able to render an individual hash" do
92
+ Orthor.render(@json["content"].first, :brief).should == "<h2>User Manual updated</h2>"
93
+ end
94
+ end
95
+
96
+ describe "with a non-existant template" do
97
+ it "should raise an error" do
98
+ lambda { Orthor.category("category", :blah) }.should raise_error(Orthor::Templates::Unknown)
99
+ end
100
+ end
101
+
102
+ describe "template mapping" do
103
+ before(:all) do
104
+ @json = Orthor.category("varied")
105
+ end
106
+
107
+ it "should allow you to pass a hash of orthor template names" do
108
+ Orthor.render(@json["content"], { "Brief News Item" => :basic, "Link" => :brief, "Bookmark" => :blurb }).should == "<h2>User Manual updated</h2><div class=\"content\">The User Manual has recently had a big overhaul with increased documentation for lots of Orthor's great features, including Content Queries, RSS Feeds, Custom CSS and more!</div><div>All accounts now have access to event tracking, use this to gain a snapshot of what everyone in your account has been doing, for example, what were the...</div><h2>Tutorials have been updated</h2><div class=\"content\">We've recently made the tutorials section of the site public, stay tuned for some more language examples + increased detail of the existing languages. </div><h2>Content Queries have landed in Orthor</h2>"
109
+ end
110
+
111
+ describe "with a named map" do
112
+ it "should find the template from the map" do
113
+ Orthor.content("content", :listing).should == "<h2>What is Orthor</h2>"
114
+ end
115
+ end
116
+
117
+ describe "when a mapping is missing" do
118
+ it "should raise an Unknown template exception" do
119
+ lambda do
120
+ Orthor.render(@json["content"], { "Brief News Item" => :basic, "Link" => :brief })
121
+ end.should raise_error(Orthor::Templates::Unknown)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,130 @@
1
+ require 'spec_helper'
2
+
3
+ describe Orthor do
4
+ before(:all) do
5
+ FakeWeb.register_uri(:get, "http://content.orthor.com/orthor/content_items/content.json",
6
+ :body => File.join(SPEC_DIR, 'resources', 'content.json'))
7
+ FakeWeb.register_uri(:get, "http://content.orthor.com/orthor/queries/query.json",
8
+ :body => File.join(SPEC_DIR, 'resources', 'query.json'))
9
+ FakeWeb.register_uri(:get, "http://content.orthor.com/orthor/categories/category.json",
10
+ :body => File.join(SPEC_DIR, 'resources', 'category.json'))
11
+ FakeWeb.register_uri(:get, "http://content.orthor.com/orthor/feeds/feed.rss",
12
+ :body => File.join(SPEC_DIR, 'resources', 'feed.rss'))
13
+ end
14
+
15
+ describe "setup" do
16
+ before(:all) do
17
+ Orthor.setup do
18
+ account nil
19
+ end
20
+ end
21
+
22
+ it "should raise an error if no account id is present" do
23
+ lambda { Orthor.content("content") }.should raise_error
24
+ end
25
+ end
26
+
27
+ describe "getting" do
28
+ before(:all) do
29
+ Orthor.setup do
30
+ account "orthor"
31
+ end
32
+ end
33
+
34
+ it "should return a Hash for a content item" do
35
+ Orthor.content("content").should be_a(Hash)
36
+ end
37
+
38
+ it "should return an array for a query" do
39
+ Orthor.query("query").should be_an(Array)
40
+ end
41
+
42
+ it "should return a hash for a category" do
43
+ Orthor.category("category").should be_a(Hash)
44
+ end
45
+
46
+ it "should return a string for a feed" do
47
+ Orthor.feed("feed").should be_a(String)
48
+ end
49
+
50
+ it 'should be able to fetch content items' do
51
+ Orthor.content("content").to_s.should include("<h2>So what is Orthor really??</h2>")
52
+ end
53
+
54
+ it 'should be able to fetch queries' do
55
+ Orthor.query("query").to_s.should include("big overhaul with increased documentation for lots of")
56
+ end
57
+
58
+ it 'should be able to fetch categories' do
59
+ Orthor.category("category").to_s.should include("The User Manual has recently had a big overhaul")
60
+ end
61
+ end
62
+
63
+ describe "caching" do
64
+ describe "no config" do
65
+ before(:all) do
66
+ Orthor.setup do
67
+ account "orthor"
68
+ end
69
+ end
70
+
71
+ it 'should not cache content if no config is given' do
72
+ Orthor.cache.should == false
73
+ end
74
+ end
75
+
76
+ describe "with config" do
77
+ describe "memory" do
78
+ before(:all) do
79
+ Orthor.setup do
80
+ account "orthor"
81
+ caching :memory, 600
82
+ end
83
+ end
84
+
85
+ it "should create a moneta cache class" do
86
+ Orthor.cache.should be_a(Moneta::Memory)
87
+ end
88
+
89
+ it "should set a cache expiry" do
90
+ Orthor.cache_expiry.should == 600
91
+ end
92
+
93
+ it 'should add content to the cache after requests' do
94
+ Orthor.cache["content-content_items"].should be_empty
95
+ Orthor.content("content")
96
+ Orthor.cache["content-content_items"].should_not be_empty
97
+
98
+ Orthor.should_not_receive(:cache_content)
99
+ Orthor.content("content")
100
+ end
101
+ end
102
+
103
+ describe "basic file store" do
104
+ before(:all) do
105
+ Orthor.setup do
106
+ account "orthor"
107
+ caching :basic_file, 300, :path => "tmp/"
108
+ end
109
+ end
110
+
111
+ it "should create a moneta basic file cache class" do
112
+ Orthor.cache.should be_a(Moneta::BasicFile)
113
+ end
114
+ end
115
+
116
+ describe "optinal expiry time argument" do
117
+ before(:all) do
118
+ Orthor.setup do
119
+ account "orthor"
120
+ caching :basic_file, :path => "tmp/"
121
+ end
122
+ end
123
+
124
+ it "should default to no cache expiry" do
125
+ Orthor.cache_expiry.should be_nil
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,6 @@
1
+ require 'rubygems'
2
+ require 'spec'
3
+ require 'fake_web'
4
+ require File.join(File.dirname(__FILE__), '..', 'lib', 'orthor')
5
+
6
+ SPEC_DIR = File.dirname(__FILE__) unless defined? SPEC_DIR
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: orthor
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 1
8
+ - 0
9
+ version: 0.1.0
10
+ platform: ruby
11
+ authors:
12
+ - Anthony Langhorne
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2010-06-10 00:00:00 +10:00
18
+ default_executable:
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ version_requirements: &id001 !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ segments:
26
+ - 0
27
+ version: "0"
28
+ name: moneta
29
+ prerelease: false
30
+ requirement: *id001
31
+ type: :runtime
32
+ - !ruby/object:Gem::Dependency
33
+ version_requirements: &id002 !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ segments:
38
+ - 0
39
+ version: "0"
40
+ name: json
41
+ prerelease: false
42
+ requirement: *id002
43
+ type: :runtime
44
+ description:
45
+ email: anthony@orthor.com
46
+ executables: []
47
+
48
+ extensions: []
49
+
50
+ extra_rdoc_files:
51
+ - LICENSE
52
+ - README.md
53
+ files:
54
+ - lib/orthor.rb
55
+ - lib/orthor/templates.rb
56
+ - LICENSE
57
+ - README.md
58
+ has_rdoc: true
59
+ homepage: http://github.com/anthony/orthor-client
60
+ licenses: []
61
+
62
+ post_install_message:
63
+ rdoc_options:
64
+ - --charset=UTF-8
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ segments:
72
+ - 0
73
+ version: "0"
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ segments:
79
+ - 0
80
+ version: "0"
81
+ requirements: []
82
+
83
+ rubyforge_project:
84
+ rubygems_version: 1.3.6
85
+ signing_key:
86
+ specification_version: 3
87
+ summary: A gem for displaying content from orthor.com
88
+ test_files:
89
+ - spec/orthor/templates_spec.rb
90
+ - spec/orthor_spec.rb
91
+ - spec/spec_helper.rb