wcc-contentful 1.4.0 → 1.5.0

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: 3fd731fc709f8ecfcd4ee730a6bc52e693bdd5f160ba00ad4cb5a50978c8a735
4
+ data.tar.gz: 20b9b0b06f31eace48c3ababb1c4cbbbc6d2a8143bc29677957305c4ce4dfe23
5
5
  SHA512:
6
- metadata.gz: 4a26f8d7e360cac5511bf68fd083c980f0ab8d9f5c81657b363f157c2c811fa0316bcd6987fff4ae0225a74db8450a196d2a21cafee9d6cb42213d9c55556a4f
7
- data.tar.gz: 26299e68f65c992b226c8c17064913454d582795ac2bd2345784f0b4550f73f77047bf8f3a67d2110652843508d885f20f45d6202bb36ede4b3d7b3244b0d6b7
6
+ metadata.gz: 9d2200e5b8bbb1c4a1b52a9163fa06c48d90049cc03715359610ab2e3b62d776635d174636b275188489da2cea857ca5a001426489513b15b2d31de749340b16
7
+ data.tar.gz: afb2143f431c139dabfa7139eedb27984f0846e6c377efb2cdea8d61336b1532e97ebba18483ea53ff7826e95b1304afeff99805c5ed750e37b93001cfb9067b
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 :configuration, :store, :model_namespace
43
+
44
+ def initialize(document, configuration: nil, store: nil, model_namespace: nil)
45
+ @document = document
46
+ @configuration = configuration if configuration.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 configuration&.app_url.present?
278
+ begin
279
+ URI(configuration.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
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Constructs new connected RichTextRenderer instances w/ needed dependencies
4
+ class RichTextRendererFactory
5
+ def initialize(implementation_class, services: WCC::Contentful::Services.instance)
6
+ @implementation_class = implementation_class
7
+ @services = services
8
+ end
9
+
10
+ def new(document)
11
+ @implementation_class.new(document).tap do |renderer|
12
+ # Inject any dependencies that the renderer needs (except itself to avoid infinite recursion)
13
+ @services.inject_into(renderer, except: [:rich_text_renderer])
14
+ end
15
+ end
16
+
17
+ def call(document)
18
+ new(document).call
19
+ end
20
+ end
@@ -11,10 +11,15 @@ module WCC::Contentful
11
11
 
12
12
  attr_reader :configuration
13
13
 
14
- def initialize(configuration)
14
+ def model_namespace
15
+ @model_namespace || WCC::Contentful::Model
16
+ end
17
+
18
+ def initialize(configuration, model_namespace: nil)
15
19
  raise ArgumentError, 'Not yet configured!' unless configuration
16
20
 
17
21
  @configuration = configuration
22
+ @model_namespace = model_namespace
18
23
  end
19
24
 
20
25
  # Gets the data-store which executes the queries run against the dynamic
@@ -123,6 +128,28 @@ module WCC::Contentful
123
128
  end
124
129
  end
125
130
 
131
+ # Returns a callable object which can be used to render a rich text document.
132
+ # This object will have all the connected services injected into it.
133
+ # The implementation class is configured by {WCC::Contentful::Configuration#rich_text_renderer}.
134
+ # In a rails context the default implementation is {WCC::Contentful::ActionViewRichTextRenderer}.
135
+ def rich_text_renderer
136
+ @rich_text_renderer ||=
137
+ if implementation_class = configuration&.rich_text_renderer
138
+ RichTextRendererFactory.new(implementation_class, services: self)
139
+ else
140
+ # Create a renderer that renders a more helpful error message, but delay the error message until #to_html
141
+ # is actually invoked in case the user never actually uses the renderer.
142
+ Class.new(WCC::Contentful::RichTextRenderer) do
143
+ def call
144
+ raise WCC::Contentful::RichTextRenderer::AbstractRendererError,
145
+ 'No rich text renderer implementation has been configured. ' \
146
+ 'Please install a supported implementation such as ActionView, ' \
147
+ 'or set WCC::Contentful.configuration.rich_text_renderer to a custom implementation.'
148
+ end
149
+ end
150
+ end
151
+ end
152
+
126
153
  # Gets the configured instrumentation adapter, defaulting to ActiveSupport::Notifications
127
154
  def instrumentation
128
155
  @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'
6
6
  end
7
7
  end
@@ -20,6 +20,9 @@ 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'
25
+ require 'wcc/contentful/rich_text_renderer_factory'
23
26
  require 'wcc/contentful/sync_engine'
24
27
  require 'wcc/contentful/events'
25
28
  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
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-08 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,8 @@ 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
457
+ - lib/wcc/contentful/rich_text_renderer_factory.rb
454
458
  - lib/wcc/contentful/rspec.rb
455
459
  - lib/wcc/contentful/services.rb
456
460
  - lib/wcc/contentful/simple_client.rb
@@ -495,7 +499,7 @@ homepage: https://github.com/watermarkchurch/wcc-contentful/wcc-contentful
495
499
  licenses:
496
500
  - MIT
497
501
  metadata:
498
- documentation_uri: https://watermarkchurch.github.io/wcc-contentful/1.4/wcc-contentful
502
+ documentation_uri: https://watermarkchurch.github.io/wcc-contentful/1.5/wcc-contentful
499
503
  rubygems_mfa_required: 'true'
500
504
  post_install_message:
501
505
  rdoc_options: []
@@ -523,11 +527,12 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
523
527
  [contentful_model](https://github.com/contentful/contentful_model), and [contentful_rails](https://github.com/contentful/contentful_rails)
524
528
  gems all in one. Table of Contents: 1. [Why?](#why-did-you-rewrite-the-contentful-ruby-stack)
525
529
  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)
530
+ 1. [Model API](#wcccontentfulmodel-api) * [Rich Text Support](#rich-text-support)
531
+ 2. [Store API](#store-api) 3. [Direct CDN client](#direct-cdn-api-simpleclient)
532
+ 4. [Accessing the APIs](#accessing-the-apis-within-application-code) 5. [Architecture](#architecture)
533
+ 1. [Client Layer](#client-layer) 2. [Store Layer](#store-layer) 3. [Model Layer](#model-layer)
534
+ 6. [Test Helpers](#test-helpers) 7. [Advanced Configuration Example](#advanced-configuration-example)
535
+ 8. [Connecting to Multiple Spaces](#connecting-to-multiple-spaces-or-environments)
531
536
  9. [Development](#development) 10. [Contributing](#contributing) 11. [License](#license) ##
532
537
  Why did you rewrite the Contentful ruby stack? We started working with Contentful
533
538
  almost 5 years ago. Since that time, Contentful''s ruby stack has improved, but
@@ -619,7 +624,7 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
619
624
  $ gem install wcc-contentful ``` ## Configure Put this in an initializer: ```ruby
620
625
  # config/initializers/wcc_contentful.rb WCC::Contentful.configure do |config| config.access_token
621
626
  = <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) ##
627
+ ``` 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
628
  Usage ### WCC::Contentful::Model API The WCC::Contentful::Model API exposes Contentful
624
629
  data as a set of dynamically generated Ruby objects. These objects are based on
625
630
  the content types in your Contentful space. All these objects are generated by
@@ -640,12 +645,34 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
640
645
  = WCC::Contentful::Model::Redirect.find_by({ slug: ''draft-redirect'' }, preview:
641
646
  true) # => #<WCC::Contentful::Model::Redirect:0x0000000005d879ad @created_at=2018-04-16
642
647
  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'')
648
+ ``` See the {WCC::Contentful::Model} documentation for more details. #### Rich
649
+ Text support As of version 1.5.0, the Model API supports parsing and rendering
650
+ Rich Text fields. Rich Text fields are retrieved from the API and parsed into the
651
+ WCC::Contentful::RichText::Document object model. ```rb Page.find_by(slug: ''/some-slug'').my_rich_text
652
+ # => #<struct WCC::Contentful::RichText::Document ... ``` If you are using Rails,
653
+ a rich text field can be rendered to HTML using the default renderer by calling
654
+ #to_html: ```rb my_rich_text.to_html # => "<div class=\"contentful-rich-text\"><h2>Dear
655
+ Watermark Family,</h2> ``` If you are not using Rails, or if you want to override
656
+ the default rendering behavior, you need to set the WCC::Contentful::Configuration#rich_text_field
657
+ configuration option: ```rb # lib/my_rich_text_renderer class MyRichTextRenderer
658
+ < WCC::Contentful::ActionViewRichTextRenderer def render_hyperlink(node) # override
659
+ the default logic for rendering hyperlinks end end # config/initializers/wcc_contentful.rb
660
+ WCC::Contentful.configure do |config| config.rich_text_renderer = MyRichTextRenderer
661
+ end ``` If you want to construct and render WCC::Contentful::RichText::Document
662
+ objects directly, the #to_html method will raise an error. Instead, you will need
663
+ to construct and invoke your renderer directly. ```rb my_document = WCC::Contentful::RichText.tokenize(JSON.parse(...contentful
664
+ CDN rich text field representation...)) # => #<struct WCC::Contentful::RichText::Document
665
+ ... renderer = MyRichTextRenderer.new(my_document, # (optional) inject services
666
+ so the renderer can automatically resolve links to entries and assets. # The renderer
667
+ still works without this, but hyperlinks which reference Assets or Entries will
668
+ raise an error. config: WCC::Contentful.configuration, store: WCC::Contentful::Services.instance.store,
669
+ model_namespace: WCC::Contentful::Model) # => #<MyRichTextRenderer:0x0000000005c71a78 renderer.call
670
+ # => "<div class=\"contentful-rich-text\"><h2>Dear Watermark Family,</h2> ``` ###
671
+ Store API The Store layer is used by the Model API to access Contentful data in
672
+ a raw form. The Store layer returns entries as hashes parsed from JSON, conforming
673
+ to the object structure returned from the Contentful CDN. The following examples
674
+ show how to use the Store API to retrieve raw data from the store: ```ruby store
675
+ = WCC::Contentful::Services.instance.store # => #<WCC::Contentful::Store::CDNAdapter:0x00007fb92a221498 store.find(''5FsqsbMECsM62e04U8sY4Y'')
649
676
  # => {"sys"=> # ... # "fields"=> # ...} store.find_by(content_type: ''page'',
650
677
  filter: { slug: ''/some-slug'' }) # => {"sys"=> # ... # "fields"=> # ...} query
651
678
  = store.find_all(content_type: ''page'').eq(''group'', ''some-group'') # => #<WCC::Contentful::Store::CDNAdapter::Query:0x00007fa3d40b84f0
@@ -692,7 +719,7 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
692
719
  < ApplicationJob include WCC::Contentful::ServiceAccessors def perform Page.find(...) store.find(...) client.entries(...)
693
720
  end end ``` ## Architecture ![wcc-contentful diagram](./doc-static/wcc-contentful.png) From
694
721
  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/)
722
+ to access the [Contentful Content Delivery API](https://www.contentful.com/developers/docs/references/content-delivery-api/)
696
723
  through your favorite HTTP client gem. The SimpleClient expects an Adapter that
697
724
  conforms to the Faraday interface. Creating a SimpleClient to connect using different
698
725
  credentials, or to connect without setting up all the rest of WCC::Contentful, is
@@ -711,7 +738,7 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
711
738
  your own store. In your RSpec suite: ```ruby # frozen_string_literal: true require
712
739
  ''my_store'' require ''wcc/contentful/store/rspec_examples'' RSpec.describe MyStore
713
740
  do it_behaves_like ''contentful store'', { # Set which store features your store
714
- implements. nested_queries: true, # Does your store implement JOINs? include_param:
741
+ implements. nested_queries: true, # Does your store implement JOINs? include_param:
715
742
  true # Does your store resolve links when given the :include option? } ``` The
716
743
  store is kept up-to-date by the {WCC::Contentful::SyncEngine}. The `SyncEngine#next`
717
744
  methodcalls the `#index` method on the configured store in order to update it with
@@ -827,7 +854,7 @@ summary: '[![Gem Version](https://badge.fury.io/rb/wcc-contentful.svg)](https://
827
854
  in a class defined inside the `app` directory, this will have the effect of deleting
828
855
  all configuration that was set in the initializer as well as the constants generated
829
856
  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
857
+ constant MySecondSpace::MyContentType)` if you try to reference a subclass such
831
858
  as `MyContentType < MySecondSpace::MyContentType` * `ArgumentError (Not yet configured!)`
832
859
  if you try to `MySecondSpace.find(''xxxx'')` to load an Entry or Asset The solution
833
860
  is to have your secondary namespace in a folder which is not in the `autoload_paths`.