wcc-contentful-app 0.2.2

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.
@@ -0,0 +1,192 @@
1
+
2
+ import Migration from 'contentful-migration-cli'
3
+
4
+ export = function (migration: Migration) {
5
+ const menu = migration.createContentType('menu', {
6
+ displayField: 'name',
7
+ name: 'Menu',
8
+ description: 'A Menu contains a number of Menu Buttons or other Menus, ' +
9
+ 'which will be rendered as drop-downs.'
10
+ })
11
+
12
+ menu.createField('name', {
13
+ name: 'Menu Name',
14
+ type: 'Symbol',
15
+ localized: false,
16
+ required: true,
17
+ validations: [],
18
+ disabled: false,
19
+ omitted: false
20
+ })
21
+
22
+ menu.createField('items', {
23
+ name: 'Items',
24
+ type: 'Array',
25
+ localized: false,
26
+ required: false,
27
+ validations: [],
28
+ disabled: false,
29
+ omitted: false,
30
+ items:
31
+ {
32
+ type: 'Link',
33
+ validations:
34
+ [{
35
+ linkContentType:
36
+ ['dropdownMenu',
37
+ 'menuButton'],
38
+ message: 'The items must be either buttons or drop-down menus.'
39
+ }],
40
+ linkType: 'Entry'
41
+ }
42
+ })
43
+
44
+ menu.changeEditorInterface('name', 'singleLine')
45
+ menu.changeEditorInterface('items', 'entryLinksEditor')
46
+
47
+ const menubutton = migration.createContentType('menuButton', {
48
+ displayField: 'text',
49
+ name: 'Menu Button',
50
+ description: 'A Menu Button is a clickable button that goes on a Menu. It has a link to a Page or a URL.'
51
+ })
52
+
53
+ menubutton.createField('text', {
54
+ name: 'Text',
55
+ type: 'Symbol',
56
+ localized: false,
57
+ required: true,
58
+ validations:
59
+ [{
60
+ size:
61
+ {
62
+ min: 1,
63
+ max: 60
64
+ },
65
+ message: 'A Menu Button should have a very short text field - ideally a single word. Please limit the text to 60 characters.'
66
+ }],
67
+ disabled: false,
68
+ omitted: false
69
+ })
70
+
71
+ menubutton.createField('icon', {
72
+ name: 'Icon',
73
+ type: 'Link',
74
+ localized: false,
75
+ required: false,
76
+ validations: [{ linkMimetypeGroup: ['image'] }],
77
+ disabled: false,
78
+ omitted: false,
79
+ linkType: 'Asset'
80
+ })
81
+
82
+ menubutton.createField('externalLink', {
83
+ name: 'External Link',
84
+ type: 'Symbol',
85
+ localized: false,
86
+ required: false,
87
+ validations:
88
+ [{
89
+ regexp: { pattern: '^(\\w+):(\\/\\/)?(\\w+:{0,1}\\w*@)?((\\w+\\.)+[^\\s\\/#]+)(:[0-9]+)?(\\/|(\\/|\\#)([\\w#!:.?+=&%@!\\-\\/]+))?$|^(\\/|(\\/|\\#)([\\w#!:.?+=&%@!\\-\\/]+))$' },
90
+ message: 'The external link must be a URL like \'https://www.watermark.org/\', a mailto url like \'mailto:info@watermark.org\', or a relative URL like \'#location-on-page\''
91
+ }],
92
+ disabled: false,
93
+ omitted: false
94
+ })
95
+
96
+ menubutton.createField('link', {
97
+ name: 'Page Link',
98
+ type: 'Link',
99
+ localized: false,
100
+ required: false,
101
+ validations:
102
+ [{
103
+ linkContentType: ['page'],
104
+ message: 'The Page Link must be a link to a Page which has a slug.'
105
+ }],
106
+ disabled: false,
107
+ omitted: false,
108
+ linkType: 'Entry'
109
+ })
110
+
111
+ menubutton.createField('ionIcon', {
112
+ name: 'Ion Icon',
113
+ type: 'Symbol',
114
+ localized: false,
115
+ required: false,
116
+ validations:
117
+ [{
118
+ regexp: { pattern: '^ion-[a-z\\-]+$' },
119
+ message: 'The icon should start with \'ion-\', like \'ion-arrow-down-c\'. See http://ionicons.com/'
120
+ }],
121
+ disabled: false,
122
+ omitted: false
123
+ })
124
+
125
+ menubutton.createField('style', {
126
+ name: 'Style',
127
+ type: 'Symbol',
128
+ localized: false,
129
+ required: false,
130
+ validations: [{ in: ['oval-border'] }],
131
+ disabled: false,
132
+ omitted: false
133
+ })
134
+
135
+ menubutton.changeEditorInterface('text', 'singleLine')
136
+ menubutton.changeEditorInterface('icon', 'assetLinkEditor')
137
+ menubutton.changeEditorInterface('externalLink', 'singleLine')
138
+ menubutton.changeEditorInterface('link', 'entryLinkEditor')
139
+ menubutton.changeEditorInterface('ionIcon', 'singleLine')
140
+ menubutton.changeEditorInterface('style', 'dropdown')
141
+
142
+ const dropdownmenu = migration.createContentType('dropdownMenu', {
143
+ displayField: 'name',
144
+ name: 'Dropdown Menu',
145
+ description: 'A Dropdown Menu can be attached to a main menu to show additional menu items on click.'
146
+ })
147
+
148
+ dropdownmenu.createField('name', {
149
+ name: 'Menu Name',
150
+ type: 'Symbol',
151
+ localized: false,
152
+ required: false,
153
+ validations: [],
154
+ disabled: false,
155
+ omitted: false
156
+ })
157
+
158
+ dropdownmenu.createField('label', {
159
+ name: 'Menu Label',
160
+ type: 'Link',
161
+ localized: false,
162
+ required: false,
163
+ validations: [{ linkContentType: ['menuButton'] }],
164
+ disabled: false,
165
+ omitted: false,
166
+ linkType: 'Entry'
167
+ })
168
+
169
+ dropdownmenu.createField('items', {
170
+ name: 'Items',
171
+ type: 'Array',
172
+ localized: false,
173
+ required: false,
174
+ validations: [],
175
+ disabled: false,
176
+ omitted: false,
177
+ items:
178
+ {
179
+ type: 'Link',
180
+ validations:
181
+ [{
182
+ linkContentType:
183
+ ['menuButton']
184
+ }],
185
+ linkType: 'Entry'
186
+ }
187
+ })
188
+
189
+ dropdownmenu.changeEditorInterface('name', 'singleLine')
190
+ dropdownmenu.changeEditorInterface('label', 'entryLinkEditor')
191
+ dropdownmenu.changeEditorInterface('items', 'entryLinksEditor')
192
+ }
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This model represents the 'menu' content type in Contentful. Any linked
4
+ # entries of the 'menu' content type will be resolved as instances of this class.
5
+ # It exposes #find, #find_by, and #find_all methods to query Contentful.
6
+ class Menu < WCC::Contentful::Model::Menu
7
+ # Add custom validations to ensure that app-specific properties exist:
8
+ # validate_field :foo, :String, :required
9
+ # validate_field :bar_links, :Array, link_to: %w[bar baz]
10
+
11
+ # Override functionality or add utilities
12
+ #
13
+ # # Example: override equality
14
+ # def ===(other)
15
+ # ...
16
+ # end
17
+ #
18
+ # # Example: override "name" attribute to always be camelized.
19
+ # # `@name` is populated by the gem in the initializer.
20
+ # def name
21
+ # @name_camelized ||= @name.camelize(true)
22
+ # end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This model represents the 'menuButton' content type in Contentful. Any linked
4
+ # entries of the 'menuButton' content type will be resolved as instances of this class.
5
+ # It exposes #find, #find_by, and #find_all methods to query Contentful.
6
+ class MenuButton < WCC::Contentful::Model::MenuButton
7
+ # Add custom validations to ensure that app-specific properties exist:
8
+ # validate_field :foo, :String, :required
9
+ # validate_field :bar_links, :Array, link_to: %w[bar baz]
10
+
11
+ # Override functionality or add utilities
12
+ #
13
+ # # Example: override equality
14
+ # def ===(other)
15
+ # ...
16
+ # end
17
+ #
18
+ # # Example: override "text" attribute to always be camelized.
19
+ # # `@text` is populated by the gem in the initializer.
20
+ # def text
21
+ # @text_camelized ||= @text.camelize(true)
22
+ # end
23
+ end
@@ -0,0 +1,50 @@
1
+ import Migration from 'contentful-migration-cli'
2
+
3
+ export = function (migration: Migration) {
4
+ const page = migration.createContentType('page')
5
+ .name('Page')
6
+ .description('A page describes a collection of sections that correspond' +
7
+ 'to a URL slug')
8
+ .displayField('title')
9
+
10
+ page.createField('title')
11
+ .name('Title')
12
+ .type('Symbol')
13
+ .required(true)
14
+
15
+ page.createField('slug')
16
+ .name('Slug')
17
+ .type('Symbol')
18
+ .required(true)
19
+ .validations([
20
+ {
21
+ unique: true
22
+ },
23
+ {
24
+ regexp: { pattern: "(\\/|\\/([\w#!:.?+=&%@!\\-\\/]))?$" },
25
+ message: "The slug must look like the path part of a URL and begin with a forward slash, example: '/my-page-slug'"
26
+ }
27
+ ])
28
+
29
+ page.createField('sections')
30
+ .name('Sections')
31
+ .type('Array')
32
+ .items({
33
+ type: 'Link',
34
+ linkType: 'Entry'
35
+ })
36
+
37
+ page.createField('subpages')
38
+ .name('Subpages')
39
+ .type('Array')
40
+ .items({
41
+ type: 'Link',
42
+ linkType: 'Entry',
43
+ validations: [
44
+ {
45
+ linkContentType: [ 'page' ]
46
+ }
47
+ ]
48
+ })
49
+
50
+ }
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This model represents the 'page' content type in Contentful. Any linked
4
+ # entries of the 'page' content type will be resolved as instances of this class.
5
+ # It exposes #find, #find_by, and #find_all methods to query Contentful.
6
+ class Page < WCC::Contentful::Model::Page
7
+ # Add custom validations to ensure that app-specific properties exist:
8
+ # validate_field :foo, :String, :required
9
+ # validate_field :bar_links, :Array, link_to: %w[bar baz]
10
+
11
+ # Override functionality or add utilities
12
+ #
13
+ # # Example: override equality
14
+ # def ===(other)
15
+ # ...
16
+ # end
17
+ #
18
+ # # Example: override "title" attribute to always be titlecase.
19
+ # # `@title` is populated by the gem in the initializer.
20
+ # def title
21
+ # @title_titlecased ||= @title.titlecase
22
+ # end
23
+ end
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+
3
+ set -e
4
+
5
+ echo "Migrating database..."
6
+ bundle exec rake db:migrate
7
+
8
+ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
9
+ $DIR/contentful migrate -y
@@ -0,0 +1,17 @@
1
+
2
+ WCC::Contentful.configure do |config|
3
+ # Required
4
+ config.access_token = # Contentful CDN access token
5
+ config.space = # Contentful Space ID
6
+
7
+ # Optional
8
+ config.management_token = # Contentful API management token
9
+ config.default_locale = # Set default locale, if left blank this is 'en-US'
10
+ config.content_delivery = # :direct, :eager_sync, or :lazy_sync
11
+ end
12
+
13
+ # Download content types, build models, and sync content
14
+ WCC::Contentful.init!
15
+
16
+ # Validate that models conform to a defined specification
17
+ WCC::Contentful.validate_models! unless defined?(Rails) && Rails.env.development?
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :wcc_contentful do
4
+ desc 'Validates content types in your space against the validations defined on your models'
5
+ task :validate, :environment do |_t|
6
+ # Ensure application models are loaded before we validate
7
+ Rails.application.eager_load!
8
+
9
+ client = Services.instance.management_client ||
10
+ Services.instance.client
11
+
12
+ content_types = client.content_types(limit: 1000).items
13
+
14
+ content_types = WCC::Contentful::ModelValidators
15
+ .transform_content_types_for_validation(content_types)
16
+
17
+ errors = WCC::Contentful::Model.schema.call(content_types)
18
+ raise WCC::Contentful::ValidationError, errors.errors unless errors.success?
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './app/exceptions'
4
+ require_relative './app/model_validators'
5
+ require_relative './ext/model'
6
+
7
+ module WCC::Contentful::App
8
+ def self.init!
9
+ raise ArgumentError, 'Please first call WCC::Contentful.init!' unless WCC::Contentful.types
10
+
11
+ # Extend all model types w/ validation & extra fields
12
+ WCC::Contentful.types.each_value do |t|
13
+ file = File.dirname(__FILE__) + "/model/#{t.name.underscore}.rb"
14
+ require file if File.exist?(file)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WCC::Contentful::App
4
+ # Raised by {WCC::Contentful.validate_models!} if a content type in the space
5
+ # does not match the validation defined on the associated model.
6
+ class ValidationError < StandardError
7
+ Message =
8
+ Struct.new(:path, :error) do
9
+ def to_s
10
+ "#{path}: #{error}"
11
+ end
12
+ end
13
+
14
+ attr_reader :errors
15
+
16
+ def initialize(errors)
17
+ @errors = ValidationError.join_msg_keys(errors)
18
+ super("Content Type Schema from Contentful failed validation!\n #{@errors.join("\n ")}")
19
+ end
20
+
21
+ # Turns the error messages hash into an array of message structs like:
22
+ # menu.fields.name.type: must be equal to String
23
+ def self.join_msg_keys(hash)
24
+ ret =
25
+ hash.map do |k, v|
26
+ if v.is_a?(Hash)
27
+ msgs = join_msg_keys(v)
28
+ msgs.map { |msg| Message.new(k.to_s + '.' + msg.path, msg.error) }
29
+ else
30
+ v.map { |msg| Message.new(k.to_s, msg) }
31
+ end
32
+ end
33
+ ret.flatten(1)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-validation'
4
+
5
+ require_relative 'model_validators/dsl'
6
+
7
+ module WCC::Contentful::App::ModelValidators
8
+ def schema
9
+ return if validations.nil? || validations.empty?
10
+
11
+ all_field_validations =
12
+ validations.each_with_object({}) do |(content_type, procs), h|
13
+ next if procs.empty?
14
+
15
+ # "page": {
16
+ # "sys": { ... }
17
+ # "fields": {
18
+ # "title": { ... },
19
+ # "sections": { ... },
20
+ # ...
21
+ # }
22
+ # }
23
+ h[content_type] =
24
+ Dry::Validation.Schema do
25
+ # Had to dig through the internals of Dry::Validation to find
26
+ # this magic incantation
27
+ procs.each { |dsl| instance_eval(&dsl.to_proc) }
28
+ end
29
+ end
30
+
31
+ Dry::Validation.Schema do
32
+ all_field_validations.each do |content_type, fields_schema|
33
+ required(content_type).schema do
34
+ required('fields').schema(fields_schema)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ def validations
41
+ # This needs to be a class variable so that subclasses defined in application
42
+ # code can add to the total package of model validations
43
+ # rubocop:disable Style/ClassVars
44
+ @@validations ||= {}
45
+ # rubocop:enable Style/ClassVars
46
+ end
47
+
48
+ # Accepts a block which uses the {dry-validation DSL}[http://dry-rb.org/gems/dry-validation/]
49
+ # to validate the 'fields' object of a content type.
50
+ def validate_fields(&block)
51
+ raise ArgumentError, 'validate_fields requires a block' unless block_given?
52
+
53
+ dsl = ProcDsl.new(Proc.new(&block))
54
+
55
+ ct = try(:content_type) || name.demodulize.camelize(:lower)
56
+ (validations[ct] ||= []) << dsl
57
+ end
58
+
59
+ # Validates a single field is of the expected type.
60
+ # Type expectations are one of:
61
+ #
62
+ # [:String] the field type must be `Symbol` or `Text`
63
+ # [:Int] the field type must be `Integer`
64
+ # [:Float] the field type must be `Number`
65
+ # [:DateTime] the field type must be 'Date'
66
+ # [:Asset] the field must be a link and the `linkType` must be `Asset`
67
+ # [:Link] the field must be a link and the `linkType` must be `Entry`.
68
+ # [:Location] the field type must be `Location`
69
+ # [:Boolean] the field type must be `Boolean`
70
+ # [:Json] the field type must be `Json` - a json blob.
71
+ # [:Array] the field must be a List.
72
+ #
73
+ # Additional validation options can be enforced:
74
+ #
75
+ # [:required] the 'Required Field' checkbox must be checked
76
+ # [:optional] the 'Required Field' checkbox must not be checked
77
+ # [:link_to] (only `:Link` or `:Array` type) the given content type(s) must be
78
+ # checked in the 'Accept only specified entry type' validations
79
+ # Example:
80
+ # validate_field :button, :Link, link_to: ['button', 'altButton']
81
+ #
82
+ # [:items] (only `:Array` type) the items of the list must be of the given type.
83
+ # Example:
84
+ # validate_field :my_strings, :Array, items: :String
85
+ #
86
+ # Examples:
87
+ # see WCC::Contentful::Model::Menu and WCC::Contentful::Model::MenuButton
88
+ def validate_field(field, type, *options)
89
+ dsl = FieldDsl.new(field, type, options)
90
+
91
+ ct = try(:content_type) || name.demodulize.camelize(:lower)
92
+ (validations[ct] ||= []) << dsl
93
+ end
94
+
95
+ def no_validate_field(field)
96
+ ct = try(:content_type) || name.demodulize.camelize(:lower)
97
+ return unless v = validations[ct]
98
+
99
+ field = field.to_s.camelize(:lower) unless field.is_a?(String)
100
+ v.reject! { |dsl| dsl.try(:field) == field }
101
+ end
102
+
103
+ # Accepts a content types response from the API and transforms it
104
+ # to be acceptible for the validator.
105
+ def self.transform_content_types_for_validation(content_types)
106
+ if !content_types.is_a?(Array) && items = content_types.try(:[], 'items')
107
+ content_types = items
108
+ end
109
+
110
+ # Transform the array into a hash keyed by content type ID
111
+ content_types.each_with_object({}) do |ct, ct_hash|
112
+ # Transform the fields into a hash keyed by field ID
113
+ ct['fields'] =
114
+ ct['fields'].each_with_object({}) do |f, f_hash|
115
+ f_hash[f['id']] = f
116
+ end
117
+
118
+ ct_hash[ct.dig('sys', 'id')] = ct
119
+ end
120
+ end
121
+ end