wcc-contentful 1.4.0 → 1.5.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63f3ef5be57de2373cfb7af9def7e9227fe0be9d7acc22977f650f4638f8835d
4
- data.tar.gz: 80021d370e0b6e4238515658f491cd6fee5bafe39c813822dcf3e44d86d7a6fa
3
+ metadata.gz: 81257133691833969ac9f77447b91d2698aa0738964b56f80e8533dd403e90d1
4
+ data.tar.gz: 424597dcc95a76513d70226d82aafbea7baf614dcc7c5ee55c405e2dc75d4337
5
5
  SHA512:
6
- metadata.gz: 4a26f8d7e360cac5511bf68fd083c980f0ab8d9f5c81657b363f157c2c811fa0316bcd6987fff4ae0225a74db8450a196d2a21cafee9d6cb42213d9c55556a4f
7
- data.tar.gz: 26299e68f65c992b226c8c17064913454d582795ac2bd2345784f0b4550f73f77047bf8f3a67d2110652843508d885f20f45d6202bb36ede4b3d7b3244b0d6b7
6
+ metadata.gz: 23e37d1e5e4271c0f0a13f8e0aa495473b55164b855b58df1638de5fc88f50e00499a4726908f1bb59856075833ab275fa3a893233fc484ff6c839fd42f023b0
7
+ data.tar.gz: 139b207faa2d1f6a3ae44ddff0421d4572f7b93aab04c2fc63bd6136e05faa72886d639ac023c0009af0934a74d8c70b79d7ce23876903706dd9702c04fb5f8a
data/README.md CHANGED
@@ -15,6 +15,7 @@ Table of Contents:
15
15
  3. [Configuration](#configure)
16
16
  4. [Usage](#usage)
17
17
  1. [Model API](#wcccontentfulmodel-api)
18
+ * [Rich Text Support](#rich-text-support)
18
19
  2. [Store API](#store-api)
19
20
  3. [Direct CDN client](#direct-cdn-api-simpleclient)
20
21
  4. [Accessing the APIs](#accessing-the-apis-within-application-code)
@@ -146,7 +147,7 @@ WCC::Contentful.init!
146
147
  ```
147
148
 
148
149
  All configuration options can be found [in the rubydoc under
149
- WCC::Contentful::Configuration](https://watermarkchurch.github.io/wcc-contentful/latest/wcc-contentful/WCC/Contentful/Configuration)
150
+ WCC::Contentful::Configuration](https://watermarkchurch.github.io/wcc-contentful/latest/wcc-contentful/WCC/Contentful/Configuration)
150
151
 
151
152
  ## Usage
152
153
 
@@ -198,6 +199,57 @@ preview_redirect_object.href
198
199
 
199
200
  See the {WCC::Contentful::Model} documentation for more details.
200
201
 
202
+ #### Rich Text support
203
+
204
+ As of version 1.5.0, the Model API supports parsing and rendering Rich Text fields.
205
+
206
+ Rich Text fields are retrieved from the API and parsed into the WCC::Contentful::RichText::Document object model.
207
+ ```rb
208
+ Page.find_by(slug: '/some-slug').my_rich_text
209
+ # => #<struct WCC::Contentful::RichText::Document ...
210
+ ```
211
+
212
+ If you are using Rails, a rich text field can be rendered to HTML using the default renderer by
213
+ calling #to_html:
214
+ ```rb
215
+ my_rich_text.to_html
216
+ # => "<div class=\"contentful-rich-text\"><h2>Dear Watermark Family,</h2>
217
+ ```
218
+
219
+ If you are not using Rails, or if you want to override the default rendering behavior, you need to set the
220
+ WCC::Contentful::Configuration#rich_text_field configuration option:
221
+ ```rb
222
+ # lib/my_rich_text_renderer
223
+ class MyRichTextRenderer < WCC::Contentful::ActionViewRichTextRenderer
224
+ def render_hyperlink(node)
225
+ # override the default logic for rendering hyperlinks
226
+ end
227
+ end
228
+
229
+ # config/initializers/wcc_contentful.rb
230
+ WCC::Contentful.configure do |config|
231
+ config.rich_text_renderer = MyRichTextRenderer
232
+ end
233
+ ```
234
+
235
+ If you want to construct and render WCC::Contentful::RichText::Document objects directly, the #to_html method will
236
+ raise an error. Instead, you will need to construct and invoke your renderer directly.
237
+ ```rb
238
+ my_document = WCC::Contentful::RichText.tokenize(JSON.parse(...contentful CDN rich text field representation...))
239
+ # => #<struct WCC::Contentful::RichText::Document ...
240
+
241
+ renderer = MyRichTextRenderer.new(my_document,
242
+ # (optional) inject services so the renderer can automatically resolve links to entries and assets.
243
+ # The renderer still works without this, but hyperlinks which reference Assets or Entries will raise an error.
244
+ config: WCC::Contentful.configuration,
245
+ store: WCC::Contentful::Services.instance.store,
246
+ model_namespace: WCC::Contentful::Model)
247
+ # => #<MyRichTextRenderer:0x0000000005c71a78
248
+
249
+ renderer.call
250
+ # => "<div class=\"contentful-rich-text\"><h2>Dear Watermark Family,</h2>
251
+ ```
252
+
201
253
  ### Store API
202
254
 
203
255
  The Store layer is used by the Model API to access Contentful data in a raw form.
@@ -359,7 +411,7 @@ From the bottom up:
359
411
 
360
412
  ### Client Layer
361
413
 
362
- The {WCC::Contentful::SimpleClient} provides methods to access the [Contentful
414
+ The {WCC::Contentful::SimpleClient} provides methods to access the [Contentful
363
415
  Content Delivery API](https://www.contentful.com/developers/docs/references/content-delivery-api/)
364
416
  through your favorite HTTP client gem. The SimpleClient expects
365
417
  an Adapter that conforms to the Faraday interface.
@@ -403,7 +455,7 @@ require 'wcc/contentful/store/rspec_examples'
403
455
 
404
456
  RSpec.describe MyStore do
405
457
  it_behaves_like 'contentful store', {
406
- # Set which store features your store implements.
458
+ # Set which store features your store implements.
407
459
  nested_queries: true, # Does your store implement JOINs?
408
460
  include_param: true # Does your store resolve links when given the :include option?
409
461
  }
@@ -695,7 +747,7 @@ defined inside the `app` directory, this will have the effect of deleting all co
695
747
  as well as the constants generated from your schema.
696
748
  This will result in one of two errors:
697
749
 
698
- * `NameError (uninitialized constant MySecondSpace::MyContentType)`
750
+ * `NameError (uninitialized constant MySecondSpace::MyContentType)`
699
751
  if you try to reference a subclass such as `MyContentType < MySecondSpace::MyContentType`
700
752
  * `ArgumentError (Not yet configured!)`
701
753
  if you try to `MySecondSpace.find('xxxx')` to load an Entry or Asset
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wcc/contentful'
4
+
5
+ namespace :wcc_contentful do
6
+ desc 'Rewrites JSON fixtures from locale=* to locale=en-US'
7
+ task :rewrite_fixtures, %i[glob locale] => :environment do |_t, args|
8
+ glob = args[:glob] || 'spec/fixtures/**/*.json'
9
+ locale = args[:locale] || 'en-US'
10
+
11
+ Dir.glob(glob) do |filename|
12
+ next unless File.file?(filename)
13
+
14
+ contents = JSON.parse(File.read(filename))
15
+ next unless contents.is_a?(Hash) && contents['sys']
16
+
17
+ rewritten_contents =
18
+ case contents['sys']['type']
19
+ when 'Array'
20
+ rewrite_array(contents, locale: locale)
21
+ when 'Entry', 'Asset'
22
+ rewrite_entry(contents, locale: locale)
23
+ end
24
+ next unless rewritten_contents
25
+
26
+ File.write(filename, JSON.pretty_generate(rewritten_contents))
27
+ end
28
+ end
29
+
30
+ def rewrite_array(contents, locale: 'en-US')
31
+ contents['items'] =
32
+ contents['items'].map do |item|
33
+ rewrite_entry(item, locale: locale)
34
+ end
35
+ if contents['includes']
36
+ if contents['includes']['Entry']
37
+ contents['includes']['Entry'] =
38
+ contents['includes']['Entry'].map do |item|
39
+ rewrite_entry(item, locale: locale)
40
+ end
41
+ end
42
+ if contents['includes']['Asset']
43
+ contents['includes']['Asset'] =
44
+ contents['includes']['Asset'].map do |item|
45
+ rewrite_entry(item, locale: locale)
46
+ end
47
+ end
48
+ end
49
+
50
+ contents
51
+ end
52
+
53
+ def rewrite_entry(contents, locale: 'en-US')
54
+ return contents unless contents['sys']
55
+
56
+ WCC::Contentful::EntryLocaleTransformer.transform_to_locale(
57
+ contents,
58
+ locale
59
+ )
60
+ end
61
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_view'
4
+
5
+ # An implementation of the RichTextRenderer that uses ActionView helpers to implement content_tag and concat.
6
+ class WCC::Contentful::ActionViewRichTextRenderer < WCC::Contentful::RichTextRenderer
7
+ include ActionView::Helpers::TagHelper
8
+ include ActionView::Helpers::TextHelper
9
+ include ActionView::Context
10
+
11
+ # TODO: use ActionView view context to render ERB templates for embedded entries?
12
+ end
@@ -14,6 +14,7 @@ class WCC::Contentful::Configuration
14
14
  logger
15
15
  management_token
16
16
  preview_token
17
+ rich_text_renderer
17
18
  schema_file
18
19
  space
19
20
  store
@@ -77,6 +78,12 @@ class WCC::Contentful::Configuration
77
78
  # Default: 2.seconds
78
79
  attr_accessor :sync_retry_wait
79
80
 
81
+ # Sets the rich text renderer implementation. This must be a class that accepts a WCC::Contentful::RichText::Document
82
+ # in the constructor, and responds to `:call` with a string containing the HTML.
83
+ # In a Rails context, the implementation defaults to WCC::Contentful::ActionViewRichTextRenderer.
84
+ # In a non-Rails context, you must provide your own implementation.
85
+ attr_accessor :rich_text_renderer
86
+
80
87
  # Returns true if the currently configured environment is pointing at `master`.
81
88
  def master?
82
89
  !environment.present?
@@ -204,6 +211,12 @@ class WCC::Contentful::Configuration
204
211
  }
205
212
  @management_token = ENV.fetch('CONTENTFUL_MANAGEMENT_TOKEN', nil)
206
213
  @preview_token = ENV.fetch('CONTENTFUL_PREVIEW_TOKEN', nil)
214
+
215
+ if defined?(ActionView)
216
+ require 'wcc/contentful/action_view_rich_text_renderer'
217
+ @rich_text_renderer = WCC::Contentful::ActionViewRichTextRenderer
218
+ end
219
+
207
220
  @space = ENV.fetch('CONTENTFUL_SPACE_ID', nil)
208
221
  @default_locale = 'en-US'
209
222
  @locale_fallbacks = {}
@@ -37,7 +37,7 @@ module WCC::Contentful::ModelAPI
37
37
  # try looking up the class heierarchy
38
38
  (superclass.services if superclass.respond_to?(:services)) ||
39
39
  # create it if we have a configuration
40
- WCC::Contentful::Services.new(configuration)
40
+ WCC::Contentful::Services.new(configuration, model_namespace: self)
41
41
  end
42
42
 
43
43
  def store(preview = nil)
@@ -104,7 +104,7 @@ module WCC::Contentful
104
104
  # when :DateTime
105
105
  # raw_value = Time.parse(raw_value).localtime
106
106
  when :RichText
107
- raw_value = WCC::Contentful::RichText.tokenize(raw_value)
107
+ raw_value = WCC::Contentful::RichText.tokenize(raw_value, renderer: ns.services.rich_text_renderer)
108
108
  when :Int
109
109
  raw_value = Integer(raw_value)
110
110
  when :Float
@@ -28,6 +28,24 @@ module WCC::Contentful::RichText
28
28
  tuple
29
29
  end
30
30
  end
31
+
32
+ # Set the renderer to use when rendering this node to HTML.
33
+ # By default a node does not have a renderer, and #to_html will raise an ArgumentError.
34
+ # However if a WCC::Contentful::RichText::Document node is created by the WCC::Contentful::ModelBuilder,
35
+ # it will inject the renderer configured in WCC::Contentful::Services#rich_text_renderer into the node.
36
+ attr_accessor :renderer
37
+
38
+ # Render the node to HTML using the configured renderer.
39
+ # See WCC::Contentful::RichTextRenderer for more information.
40
+ def to_html
41
+ unless renderer
42
+ raise ArgumentError,
43
+ 'No renderer provided during tokenization. ' \
44
+ 'Please configure the rich_text_renderer in your WCC::Contentful configuration.'
45
+ end
46
+
47
+ renderer.call(self)
48
+ end
31
49
  end
32
50
 
33
51
  class_methods do
@@ -36,8 +54,12 @@ module WCC::Contentful::RichText
36
54
  name.demodulize.underscore.dasherize
37
55
  end
38
56
 
39
- def tokenize(raw, context = nil)
40
- raise ArgumentError, "Expected '#{node_type}', got '#{raw['nodeType']}'" unless raw['nodeType'] == node_type
57
+ def matches?(node_type)
58
+ self.node_type == node_type
59
+ end
60
+
61
+ def tokenize(raw, renderer: nil)
62
+ raise ArgumentError, "Expected '#{node_type}', got '#{raw['nodeType']}'" unless matches?(raw['nodeType'])
41
63
 
42
64
  values =
43
65
  members.map do |symbol|
@@ -45,15 +67,17 @@ module WCC::Contentful::RichText
45
67
 
46
68
  case symbol
47
69
  when :content
48
- WCC::Contentful::RichText.tokenize(val, context)
49
- # when :data
50
- # TODO: resolve links...
70
+ WCC::Contentful::RichText.tokenize(val, renderer: renderer)
51
71
  else
52
72
  val
53
73
  end
54
74
  end
55
75
 
56
- new(*values)
76
+ new(*values).tap do |node|
77
+ next unless renderer
78
+
79
+ node.renderer = renderer
80
+ end
57
81
  end
58
82
  end
59
83
  end
@@ -23,9 +23,11 @@ require_relative './rich_text/node'
23
23
  module WCC::Contentful::RichText
24
24
  ##
25
25
  # Recursively converts a raw JSON-parsed hash into the RichText object model.
26
- def self.tokenize(raw, context = nil)
26
+ # If renderer are provided, the model will be able to resolve links to entries
27
+ # and enable direct rendering of documents to HTML.
28
+ def self.tokenize(raw, renderer: nil)
27
29
  return unless raw
28
- return raw.map { |c| tokenize(c, context) } if raw.is_a?(Array)
30
+ return raw.map { |c| tokenize(c) } if raw.is_a?(Array)
29
31
 
30
32
  klass =
31
33
  case raw['nodeType']
@@ -33,10 +35,26 @@ module WCC::Contentful::RichText
33
35
  Document
34
36
  when 'paragraph'
35
37
  Paragraph
38
+ when 'hr'
39
+ HR
36
40
  when 'blockquote'
37
41
  Blockquote
38
42
  when 'text'
39
43
  Text
44
+ when 'ordered-list'
45
+ OrderedList
46
+ when 'unordered-list'
47
+ UnorderedList
48
+ when 'list-item'
49
+ ListItem
50
+ when 'table'
51
+ Table
52
+ when 'table-row'
53
+ TableRow
54
+ when 'table-cell'
55
+ TableCell
56
+ when 'table-header-cell'
57
+ TableHeaderCell
40
58
  when 'embedded-entry-inline'
41
59
  EmbeddedEntryInline
42
60
  when 'embedded-entry-block'
@@ -44,13 +62,17 @@ module WCC::Contentful::RichText
44
62
  when 'embedded-asset-block'
45
63
  EmbeddedAssetBlock
46
64
  when /heading-(\d+)/
47
- size = Regexp.last_match(1)
48
- const_get("Heading#{size}")
65
+ Heading
66
+ when /(\w+-)?hyperlink/
67
+ Hyperlink
49
68
  else
69
+ # Future proofing for new node types introduced by Contentful.
70
+ # The best list of node types maintained by Contentful is here:
71
+ # https://github.com/contentful/rich-text/blob/master/packages/rich-text-types/src/blocks.ts
50
72
  Unknown
51
73
  end
52
74
 
53
- klass.tokenize(raw, context)
75
+ klass.tokenize(raw, renderer: renderer)
54
76
  end
55
77
 
56
78
  Document =
@@ -63,6 +85,11 @@ module WCC::Contentful::RichText
63
85
  include WCC::Contentful::RichText::Node
64
86
  end
65
87
 
88
+ HR =
89
+ Struct.new(:nodeType, :data, :content) do
90
+ include WCC::Contentful::RichText::Node
91
+ end
92
+
66
93
  Blockquote =
67
94
  Struct.new(:nodeType, :data, :content) do
68
95
  include WCC::Contentful::RichText::Node
@@ -73,6 +100,41 @@ module WCC::Contentful::RichText
73
100
  include WCC::Contentful::RichText::Node
74
101
  end
75
102
 
103
+ OrderedList =
104
+ Struct.new(:nodeType, :data, :content) do
105
+ include WCC::Contentful::RichText::Node
106
+ end
107
+
108
+ UnorderedList =
109
+ Struct.new(:nodeType, :data, :content) do
110
+ include WCC::Contentful::RichText::Node
111
+ end
112
+
113
+ ListItem =
114
+ Struct.new(:nodeType, :data, :content) do
115
+ include WCC::Contentful::RichText::Node
116
+ end
117
+
118
+ Table =
119
+ Struct.new(:nodeType, :data, :content) do
120
+ include WCC::Contentful::RichText::Node
121
+ end
122
+
123
+ TableRow =
124
+ Struct.new(:nodeType, :data, :content) do
125
+ include WCC::Contentful::RichText::Node
126
+ end
127
+
128
+ TableCell =
129
+ Struct.new(:nodeType, :data, :content) do
130
+ include WCC::Contentful::RichText::Node
131
+ end
132
+
133
+ TableHeaderCell =
134
+ Struct.new(:nodeType, :data, :content) do
135
+ include WCC::Contentful::RichText::Node
136
+ end
137
+
76
138
  EmbeddedEntryInline =
77
139
  Struct.new(:nodeType, :data, :content) do
78
140
  include WCC::Contentful::RichText::Node
@@ -88,18 +150,40 @@ module WCC::Contentful::RichText
88
150
  include WCC::Contentful::RichText::Node
89
151
  end
90
152
 
91
- (1..5).each do |i|
92
- struct =
93
- Struct.new(:nodeType, :data, :content) do
94
- include WCC::Contentful::RichText::Node
153
+ EmbeddedResourceBlock =
154
+ Struct.new(:nodeType, :data, :content) do
155
+ include WCC::Contentful::RichText::Node
156
+ end
157
+
158
+ Heading =
159
+ Struct.new(:nodeType, :data, :content) do
160
+ include WCC::Contentful::RichText::Node
161
+
162
+ def self.matches?(node_type)
163
+ node_type =~ /heading-(\d+)/
95
164
  end
96
- sz = i
97
- struct.define_singleton_method(:node_type) { "heading-#{sz}" }
98
- const_set("Heading#{sz}", struct)
99
- end
165
+
166
+ def size
167
+ @size ||= /heading-(\d+)/.match(nodeType)[1]&.to_i
168
+ end
169
+ end
170
+
171
+ Hyperlink =
172
+ Struct.new(:nodeType, :data, :content) do
173
+ include WCC::Contentful::RichText::Node
174
+
175
+ def self.matches?(node_type)
176
+ node_type =~ /(\w+-)?hyperlink/
177
+ end
178
+ end
100
179
 
101
180
  Unknown =
102
181
  Struct.new(:nodeType, :data, :content) do
103
182
  include WCC::Contentful::RichText::Node
183
+
184
+ # Unknown nodes are the catch all, so they always match anything that made it to the else case of the switch.
185
+ def self.matches?(_node_type)
186
+ true
187
+ end
104
188
  end
105
189
  end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The abstract base class for rendering Rich Text.
4
+ # This base class implements much of the recursive logic necessary for rendering
5
+ # Rich Text nodes, but leaves the actual rendering of the HTML tags to the
6
+ # subclasses.
7
+ #
8
+ # Subclasses can override any method to customize the rendering behavior. At a minimum they must implement
9
+ # the #content_tag and #concat methods to take advantage of the recursive rendering logic in the base class.
10
+ # The API for these methods is assumed to be equivalent to the ActionView helpers of the same name.
11
+ #
12
+ # The canonical implementation is the WCC::Contentful::ActionViewRichTextRenderer, which uses the standard ActionView
13
+ # helpers as-is to render the HTML tags.
14
+ #
15
+ # @example
16
+ # class MyRichTextRenderer < WCC::Contentful::RichTextRenderer
17
+ # def content_tag(name, options, &block)
18
+ # # your implementation here
19
+ # # for reference of expected behavior see
20
+ # # https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html#method-i-content_tag
21
+ # end
22
+ #
23
+ # def concat(html_string)
24
+ # # your implementation here
25
+ # # for reference of expected behavior see
26
+ # # https://api.rubyonrails.org/classes/ActionView/Helpers/TextHelper.html#method-i-concat
27
+ # end
28
+ # end
29
+ #
30
+ # renderer = MyRichTextRenderer.new(document)
31
+ # renderer.call
32
+ #
33
+ # @abstract
34
+ class WCC::Contentful::RichTextRenderer
35
+ class << self
36
+ def call(document, *args, **kwargs)
37
+ new(document, *args, **kwargs).call
38
+ end
39
+ end
40
+
41
+ attr_reader :document
42
+ attr_accessor :config, :store, :model_namespace
43
+
44
+ def initialize(document, config: nil, store: nil, model_namespace: nil)
45
+ @document = document
46
+ @config = config if config.present?
47
+ @store = store if store.present?
48
+ @model_namespace = model_namespace if model_namespace.present?
49
+ end
50
+
51
+ def call
52
+ render.to_s
53
+ end
54
+
55
+ def render
56
+ content_tag(:div, class: 'contentful-rich-text') do
57
+ render_content(document.content)
58
+ end
59
+ end
60
+
61
+ def render_content(content)
62
+ content&.each do |node|
63
+ concat render_node(node)
64
+ end
65
+ end
66
+
67
+ def render_node(node)
68
+ if WCC::Contentful::RichText::Heading.matches?(node.node_type)
69
+ render_heading(node)
70
+ else
71
+ public_send(:"render_#{node.node_type.underscore}", node)
72
+ end
73
+ end
74
+
75
+ def render_text(node)
76
+ return node.value unless node.marks&.any?
77
+
78
+ node.marks.reduce(node.value) do |value, mark|
79
+ next value unless type = mark['type']&.underscore
80
+
81
+ render_mark(type, value)
82
+ end
83
+ end
84
+
85
+ DEFAULT_MARKS = {
86
+ 'bold' => 'strong',
87
+ 'italic' => 'em',
88
+ 'underline' => 'u',
89
+ 'code' => 'code',
90
+ 'superscript' => 'sup',
91
+ 'subscript' => 'sub'
92
+ }.freeze
93
+
94
+ def render_mark(type, value)
95
+ return value unless tag = DEFAULT_MARKS[type]
96
+
97
+ content_tag(tag, value)
98
+ end
99
+
100
+ def render_paragraph(node)
101
+ content_tag(:p) do
102
+ render_content(node.content)
103
+ end
104
+ end
105
+
106
+ def render_heading(node)
107
+ content_tag(:"h#{node.size}") do
108
+ render_content(node.content)
109
+ end
110
+ end
111
+
112
+ def render_blockquote(node)
113
+ content_tag(:blockquote) do
114
+ render_content(node.content)
115
+ end
116
+ end
117
+
118
+ def render_hr(_node)
119
+ content_tag(:hr)
120
+ end
121
+
122
+ def render_unordered_list(node)
123
+ content_tag(:ul) do
124
+ render_content(node.content)
125
+ end
126
+ end
127
+
128
+ def render_ordered_list(node)
129
+ content_tag(:ol) do
130
+ render_content(node.content)
131
+ end
132
+ end
133
+
134
+ def render_list_item(node)
135
+ content_tag(:li) do
136
+ render_content(node.content)
137
+ end
138
+ end
139
+
140
+ def render_table(node)
141
+ content_tag(:table) do
142
+ # Check the first row - if it's a header row, render a <thead>
143
+ first, *rest = node.content
144
+ if first&.content&.all? { |cell| cell.node_type == 'table-header-cell' }
145
+ concat(content_tag(:thead) { render_content([first]) })
146
+ else
147
+ # Otherwise, render it inside the tbody with the rest
148
+ rest.unshift(first)
149
+ end
150
+
151
+ concat(content_tag(:tbody) { render_content(rest) })
152
+ end
153
+ end
154
+
155
+ def render_table_row(node)
156
+ content_tag(:tr) do
157
+ render_content(node.content)
158
+ end
159
+ end
160
+
161
+ def render_table_cell(node)
162
+ content_tag(:td) do
163
+ render_content(node.content)
164
+ end
165
+ end
166
+
167
+ def render_table_header_cell(node)
168
+ content_tag(:th) do
169
+ render_content(node.content)
170
+ end
171
+ end
172
+
173
+ def render_hyperlink(node)
174
+ content_tag(:a,
175
+ href: node.data['uri'],
176
+ # External links should be target="_blank" by default
177
+ target: ('_blank' if url_is_external?(node.data['uri']))) do
178
+ render_content(node.content)
179
+ end
180
+ end
181
+
182
+ def render_asset_hyperlink(node)
183
+ target = resolve_target(node.data['target'])
184
+ url = target&.dig('fields', 'file', 'url')
185
+
186
+ render_hyperlink(
187
+ WCC::Contentful::RichText::Hyperlink.tokenize(
188
+ node.as_json.merge(
189
+ 'nodeType' => 'hyperlink',
190
+ 'data' => node['data'].merge({
191
+ 'uri' => url,
192
+ 'target' => target.as_json
193
+ })
194
+ )
195
+ )
196
+ )
197
+ end
198
+
199
+ def render_entry_hyperlink(node)
200
+ unless model_namespace.present?
201
+ raise NotConnectedError,
202
+ 'Rendering linked entries requires a connected RichTextRenderer. Please use the one configured in ' \
203
+ 'WCC::Contentful::Services.instance or pass a model_namespace to the RichTextRenderer constructor.'
204
+ end
205
+
206
+ target = resolve_target(node.data['target'])
207
+ model_instance = model_namespace.new_from_raw(target)
208
+ unless model_instance.respond_to?(:href)
209
+ raise NotConnectedError,
210
+ "Entry hyperlinks are not supported for #{model_instance.class}. " \
211
+ 'Please ensure your model defines an #href method, or override the ' \
212
+ '#render_entry_hyperlink method in your app-specific RichTextRenderer implementation.'
213
+ end
214
+
215
+ render_hyperlink(
216
+ WCC::Contentful::RichText::Hyperlink.tokenize(
217
+ node.as_json.merge(
218
+ 'nodeType' => 'hyperlink',
219
+ 'data' => node['data'].merge({
220
+ 'uri' => model_instance.href,
221
+ 'target' => target.as_json
222
+ })
223
+ )
224
+ )
225
+ )
226
+ end
227
+
228
+ def render_embedded_asset_block(node)
229
+ target = resolve_target(node.data['target'])
230
+ title = target&.dig('fields', 'title')
231
+ url = target&.dig('fields', 'file', 'url')
232
+
233
+ content_tag(:img, src: url, alt: title) do
234
+ render_content(node.content)
235
+ end
236
+ end
237
+
238
+ def render_embedded_entry_block(_node)
239
+ raise AbstractRendererError,
240
+ 'Entry embeds are not supported. What should it look like? ' \
241
+ 'Please override this in your app-specific RichTextRenderer implementation.'
242
+ end
243
+
244
+ def render_embedded_entry_inline(_node)
245
+ raise AbstractRendererError,
246
+ 'Inline Entry embeds are not supported. What should it look like? ' \
247
+ 'Please override this in your app-specific RichTextRenderer implementation.'
248
+ end
249
+
250
+ private
251
+
252
+ def resolve_target(target)
253
+ unless store.present?
254
+ raise NotConnectedError,
255
+ 'Rendering embedded or linked entries requires a connected RichTextRenderer. Please use the one configured ' \
256
+ 'in WCC::Contentful::Services.instance or pass a store to the RichTextRenderer constructor.'
257
+ end
258
+
259
+ if target&.dig('sys', 'type') == 'Link'
260
+ target = store.find(target.dig('sys', 'id'), hint: target.dig('sys', 'linkType'))
261
+ end
262
+ target
263
+ end
264
+
265
+ def url_is_external?(url)
266
+ return false unless url.present?
267
+
268
+ uri =
269
+ begin
270
+ URI(url)
271
+ rescue StandardError
272
+ nil
273
+ end
274
+ return false unless uri&.host.present?
275
+
276
+ app_uri =
277
+ if config&.app_url.present?
278
+ begin
279
+ URI(config.app_url)
280
+ rescue StandardError
281
+ nil
282
+ end
283
+ end
284
+ uri.host != app_uri&.host
285
+ end
286
+
287
+ def content_tag(*_args)
288
+ raise AbstractRendererError, 'RichTextRenderer is an abstract class, please use an implementation subclass'
289
+ end
290
+
291
+ def concat(*_args)
292
+ raise AbstractRendererError, 'RichTextRenderer is an abstract class, please use an implementation subclass'
293
+ end
294
+
295
+ class AbstractRendererError < StandardError
296
+ end
297
+
298
+ class NotConnectedError < AbstractRendererError
299
+ end
300
+ end
@@ -11,10 +11,11 @@ module WCC::Contentful
11
11
 
12
12
  attr_reader :configuration
13
13
 
14
- def initialize(configuration)
14
+ def initialize(configuration, model_namespace: nil)
15
15
  raise ArgumentError, 'Not yet configured!' unless configuration
16
16
 
17
17
  @configuration = configuration
18
+ @model_namespace = model_namespace
18
19
  end
19
20
 
20
21
  # Gets the data-store which executes the queries run against the dynamic
@@ -123,6 +124,41 @@ module WCC::Contentful
123
124
  end
124
125
  end
125
126
 
127
+ # Returns a callable object which can be used to render a rich text document.
128
+ # This object will have all the connected services injected into it.
129
+ # The implementation class is configured by {WCC::Contentful::Configuration#rich_text_renderer}.
130
+ # In a rails context the default implementation is {WCC::Contentful::ActionViewRichTextRenderer}.
131
+ def rich_text_renderer
132
+ @rich_text_renderer ||=
133
+ if implementation_class = configuration&.rich_text_renderer
134
+ store = self.store
135
+ config = configuration
136
+ model_namespace = @model_namespace || WCC::Contentful::Model
137
+
138
+ # Wrap the implementation in a subclass that injects the services
139
+ Class.new(implementation_class) do
140
+ define_method :initialize do |document, *args, **kwargs|
141
+ # Implementation might choose to override these, so call super last
142
+ @store = store
143
+ @config = config
144
+ @model_namespace = model_namespace
145
+ super(document, *args, **kwargs)
146
+ end
147
+ end
148
+ else
149
+ # Create a renderer that renders a more helpful error message, but delay the error message until #to_html
150
+ # is actually invoked in case the user never actually uses the renderer.
151
+ Class.new(WCC::Contentful::RichTextRenderer) do
152
+ def call
153
+ raise WCC::Contentful::RichTextRenderer::AbstractRendererError,
154
+ 'No rich text renderer implementation has been configured. ' \
155
+ 'Please install a supported implementation such as ActionView, ' \
156
+ 'or set WCC::Contentful.configuration.rich_text_renderer to a custom implementation.'
157
+ end
158
+ end
159
+ end
160
+ end
161
+
126
162
  # Gets the configured instrumentation adapter, defaulting to ActiveSupport::Notifications
127
163
  def instrumentation
128
164
  @instrumentation ||=
@@ -104,7 +104,7 @@ class WCC::Contentful::SimpleClient
104
104
  def includes
105
105
  @includes ||=
106
106
  raw['includes']&.each_with_object({}) do |(_t, entries), h|
107
- entries.each { |e| h[e.dig('sys', 'id')] = e }
107
+ entries&.each { |e| h[e.dig('sys', 'id')] = e }
108
108
  end || {}
109
109
  end
110
110
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module WCC
4
4
  module Contentful
5
- VERSION = '1.4.0'
5
+ VERSION = '1.5.0.rc1'
6
6
  end
7
7
  end
@@ -20,6 +20,8 @@ require 'wcc/contentful/model'
20
20
  require 'wcc/contentful/model_methods'
21
21
  require 'wcc/contentful/model_singleton_methods'
22
22
  require 'wcc/contentful/model_builder'
23
+ require 'wcc/contentful/rich_text'
24
+ require 'wcc/contentful/rich_text_renderer'
23
25
  require 'wcc/contentful/sync_engine'
24
26
  require 'wcc/contentful/events'
25
27
  require 'wcc/contentful/middleware'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wcc-contentful
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Watermark Dev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-05-09 00:00:00.000000000 Z
11
+ date: 2023-06-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: byebug
@@ -423,7 +423,9 @@ files:
423
423
  - config/initializers/mime_types.rb
424
424
  - config/routes.rb
425
425
  - lib/tasks/download_schema.rake
426
+ - lib/tasks/rewrite_fixtures.rake
426
427
  - lib/wcc/contentful.rb
428
+ - lib/wcc/contentful/action_view_rich_text_renderer.rb
427
429
  - lib/wcc/contentful/active_record_shim.rb
428
430
  - lib/wcc/contentful/configuration.rb
429
431
  - lib/wcc/contentful/content_type_indexer.rb
@@ -451,6 +453,7 @@ files:
451
453
  - lib/wcc/contentful/rake.rb
452
454
  - lib/wcc/contentful/rich_text.rb
453
455
  - lib/wcc/contentful/rich_text/node.rb
456
+ - lib/wcc/contentful/rich_text_renderer.rb
454
457
  - lib/wcc/contentful/rspec.rb
455
458
  - lib/wcc/contentful/services.rb
456
459
  - lib/wcc/contentful/simple_client.rb
@@ -495,7 +498,7 @@ homepage: https://github.com/watermarkchurch/wcc-contentful/wcc-contentful
495
498
  licenses:
496
499
  - MIT
497
500
  metadata:
498
- documentation_uri: https://watermarkchurch.github.io/wcc-contentful/1.4/wcc-contentful
501
+ documentation_uri: https://watermarkchurch.github.io/wcc-contentful/1.5/wcc-contentful
499
502
  rubygems_mfa_required: 'true'
500
503
  post_install_message:
501
504
  rdoc_options: []
@@ -508,9 +511,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
508
511
  version: '2.7'
509
512
  required_rubygems_version: !ruby/object:Gem::Requirement
510
513
  requirements:
511
- - - ">="
514
+ - - ">"
512
515
  - !ruby/object:Gem::Version
513
- version: '0'
516
+ version: 1.3.1
514
517
  requirements: []
515
518
  rubygems_version: 3.3.7
516
519
  signing_key:
@@ -523,11 +526,12 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
523
526
  [contentful_model](https://github.com/contentful/contentful_model), and [contentful_rails](https://github.com/contentful/contentful_rails)
524
527
  gems all in one. Table of Contents: 1. [Why?](#why-did-you-rewrite-the-contentful-ruby-stack)
525
528
  2. [Installation](#installation) 3. [Configuration](#configure) 4. [Usage](#usage)
526
- 1. [Model API](#wcccontentfulmodel-api) 2. [Store API](#store-api) 3. [Direct CDN
527
- client](#direct-cdn-api-simpleclient) 4. [Accessing the APIs](#accessing-the-apis-within-application-code)
528
- 5. [Architecture](#architecture) 1. [Client Layer](#client-layer) 2. [Store Layer](#store-layer)
529
- 3. [Model Layer](#model-layer) 6. [Test Helpers](#test-helpers) 7. [Advanced Configuration
530
- Example](#advanced-configuration-example) 8. [Connecting to Multiple Spaces](#connecting-to-multiple-spaces-or-environments)
529
+ 1. [Model API](#wcccontentfulmodel-api) * [Rich Text Support](#rich-text-support)
530
+ 2. [Store API](#store-api) 3. [Direct CDN client](#direct-cdn-api-simpleclient)
531
+ 4. [Accessing the APIs](#accessing-the-apis-within-application-code) 5. [Architecture](#architecture)
532
+ 1. [Client Layer](#client-layer) 2. [Store Layer](#store-layer) 3. [Model Layer](#model-layer)
533
+ 6. [Test Helpers](#test-helpers) 7. [Advanced Configuration Example](#advanced-configuration-example)
534
+ 8. [Connecting to Multiple Spaces](#connecting-to-multiple-spaces-or-environments)
531
535
  9. [Development](#development) 10. [Contributing](#contributing) 11. [License](#license) ##
532
536
  Why did you rewrite the Contentful ruby stack? We started working with Contentful
533
537
  almost 5 years ago. Since that time, Contentful''s ruby stack has improved, but
@@ -619,7 +623,7 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
619
623
  $ gem install wcc-contentful ``` ## Configure Put this in an initializer: ```ruby
620
624
  # config/initializers/wcc_contentful.rb WCC::Contentful.configure do |config| config.access_token
621
625
  = <CONTENTFUL_ACCESS_TOKEN> config.space = <CONTENTFUL_SPACE_ID> end WCC::Contentful.init!
622
- ``` All configuration options can be found [in the rubydoc under WCC::Contentful::Configuration](https://watermarkchurch.github.io/wcc-contentful/latest/wcc-contentful/WCC/Contentful/Configuration) ##
626
+ ``` All configuration options can be found [in the rubydoc under WCC::Contentful::Configuration](https://watermarkchurch.github.io/wcc-contentful/latest/wcc-contentful/WCC/Contentful/Configuration) ##
623
627
  Usage ### WCC::Contentful::Model API The WCC::Contentful::Model API exposes Contentful
624
628
  data as a set of dynamically generated Ruby objects. These objects are based on
625
629
  the content types in your Contentful space. All these objects are generated by
@@ -640,12 +644,34 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
640
644
  = WCC::Contentful::Model::Redirect.find_by({ slug: ''draft-redirect'' }, preview:
641
645
  true) # => #<WCC::Contentful::Model::Redirect:0x0000000005d879ad @created_at=2018-04-16
642
646
  18:41:17 UTC...> preview_redirect_object.href # => ''http://www.somesite.com/slug-for-redirect''
643
- ``` See the {WCC::Contentful::Model} documentation for more details. ### Store
644
- API The Store layer is used by the Model API to access Contentful data in a raw
645
- form. The Store layer returns entries as hashes parsed from JSON, conforming to
646
- the object structure returned from the Contentful CDN. The following examples show
647
- how to use the Store API to retrieve raw data from the store: ```ruby store = WCC::Contentful::Services.instance.store
648
- # => #<WCC::Contentful::Store::CDNAdapter:0x00007fb92a221498 store.find(''5FsqsbMECsM62e04U8sY4Y'')
647
+ ``` See the {WCC::Contentful::Model} documentation for more details. #### Rich
648
+ Text support As of version 1.5.0, the Model API supports parsing and rendering
649
+ Rich Text fields. Rich Text fields are retrieved from the API and parsed into the
650
+ WCC::Contentful::RichText::Document object model. ```rb Page.find_by(slug: ''/some-slug'').my_rich_text
651
+ # => #<struct WCC::Contentful::RichText::Document ... ``` If you are using Rails,
652
+ a rich text field can be rendered to HTML using the default renderer by calling
653
+ #to_html: ```rb my_rich_text.to_html # => "<div class=\"contentful-rich-text\"><h2>Dear
654
+ Watermark Family,</h2> ``` If you are not using Rails, or if you want to override
655
+ the default rendering behavior, you need to set the WCC::Contentful::Configuration#rich_text_field
656
+ configuration option: ```rb # lib/my_rich_text_renderer class MyRichTextRenderer
657
+ < WCC::Contentful::ActionViewRichTextRenderer def render_hyperlink(node) # override
658
+ the default logic for rendering hyperlinks end end # config/initializers/wcc_contentful.rb
659
+ WCC::Contentful.configure do |config| config.rich_text_renderer = MyRichTextRenderer
660
+ end ``` If you want to construct and render WCC::Contentful::RichText::Document
661
+ objects directly, the #to_html method will raise an error. Instead, you will need
662
+ to construct and invoke your renderer directly. ```rb my_document = WCC::Contentful::RichText.tokenize(JSON.parse(...contentful
663
+ CDN rich text field representation...)) # => #<struct WCC::Contentful::RichText::Document
664
+ ... renderer = MyRichTextRenderer.new(my_document, # (optional) inject services
665
+ so the renderer can automatically resolve links to entries and assets. # The renderer
666
+ still works without this, but hyperlinks which reference Assets or Entries will
667
+ raise an error. config: WCC::Contentful.configuration, store: WCC::Contentful::Services.instance.store,
668
+ model_namespace: WCC::Contentful::Model) # => #<MyRichTextRenderer:0x0000000005c71a78 renderer.call
669
+ # => "<div class=\"contentful-rich-text\"><h2>Dear Watermark Family,</h2> ``` ###
670
+ Store API The Store layer is used by the Model API to access Contentful data in
671
+ a raw form. The Store layer returns entries as hashes parsed from JSON, conforming
672
+ to the object structure returned from the Contentful CDN. The following examples
673
+ show how to use the Store API to retrieve raw data from the store: ```ruby store
674
+ = WCC::Contentful::Services.instance.store # => #<WCC::Contentful::Store::CDNAdapter:0x00007fb92a221498 store.find(''5FsqsbMECsM62e04U8sY4Y'')
649
675
  # => {"sys"=> # ... # "fields"=> # ...} store.find_by(content_type: ''page'',
650
676
  filter: { slug: ''/some-slug'' }) # => {"sys"=> # ... # "fields"=> # ...} query
651
677
  = store.find_all(content_type: ''page'').eq(''group'', ''some-group'') # => #<WCC::Contentful::Store::CDNAdapter::Query:0x00007fa3d40b84f0
@@ -692,7 +718,7 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
692
718
  < ApplicationJob include WCC::Contentful::ServiceAccessors def perform Page.find(...) store.find(...) client.entries(...)
693
719
  end end ``` ## Architecture ![wcc-contentful diagram](./doc-static/wcc-contentful.png) From
694
720
  the bottom up: ### Client Layer The {WCC::Contentful::SimpleClient} provides methods
695
- to access the [Contentful Content Delivery API](https://www.contentful.com/developers/docs/references/content-delivery-api/)
721
+ to access the [Contentful Content Delivery API](https://www.contentful.com/developers/docs/references/content-delivery-api/)
696
722
  through your favorite HTTP client gem. The SimpleClient expects an Adapter that
697
723
  conforms to the Faraday interface. Creating a SimpleClient to connect using different
698
724
  credentials, or to connect without setting up all the rest of WCC::Contentful, is
@@ -711,7 +737,7 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
711
737
  your own store. In your RSpec suite: ```ruby # frozen_string_literal: true require
712
738
  ''my_store'' require ''wcc/contentful/store/rspec_examples'' RSpec.describe MyStore
713
739
  do it_behaves_like ''contentful store'', { # Set which store features your store
714
- implements. nested_queries: true, # Does your store implement JOINs? include_param:
740
+ implements. nested_queries: true, # Does your store implement JOINs? include_param:
715
741
  true # Does your store resolve links when given the :include option? } ``` The
716
742
  store is kept up-to-date by the {WCC::Contentful::SyncEngine}. The `SyncEngine#next`
717
743
  methodcalls the `#index` method on the configured store in order to update it with
@@ -827,7 +853,7 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
827
853
  in a class defined inside the `app` directory, this will have the effect of deleting
828
854
  all configuration that was set in the initializer as well as the constants generated
829
855
  from your schema. This will result in one of two errors: * `NameError (uninitialized
830
- constant MySecondSpace::MyContentType)` if you try to reference a subclass such
856
+ constant MySecondSpace::MyContentType)` if you try to reference a subclass such
831
857
  as `MyContentType < MySecondSpace::MyContentType` * `ArgumentError (Not yet configured!)`
832
858
  if you try to `MySecondSpace.find(''xxxx'')` to load an Entry or Asset The solution
833
859
  is to have your secondary namespace in a folder which is not in the `autoload_paths`.