jekyll-notion-cms 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+
6
+ module JekyllNotionCMS
7
+ # Jekyll Generator that fetches data from Notion databases
8
+ class Generator < Jekyll::Generator
9
+ safe true
10
+ priority :highest
11
+
12
+ def generate(site)
13
+ @site = site
14
+ @config = site.config['notion'] || {}
15
+ @collections_config = @config['collections'] || {}
16
+
17
+ unless @config['enabled'] != false
18
+ Jekyll.logger.info 'NotionCMS:', 'Plugin disabled in configuration'
19
+ return
20
+ end
21
+
22
+ unless ENV['NOTION_TOKEN']
23
+ Jekyll.logger.info 'NotionCMS:', 'No NOTION_TOKEN found, using collections fallback'
24
+ use_all_collections_fallback
25
+ return
26
+ end
27
+
28
+ Jekyll.logger.info 'NotionCMS:', 'Fetching data from Notion API...'
29
+
30
+ begin
31
+ @client = NotionClient.new(ENV.fetch('NOTION_TOKEN', nil))
32
+
33
+ @collections_config.each do |collection_name, collection_config|
34
+ fetch_collection_data(collection_name, collection_config)
35
+ end
36
+
37
+ Jekyll.logger.info 'NotionCMS:', 'All data fetched successfully'
38
+ rescue StandardError => e
39
+ Jekyll.logger.error 'NotionCMS:', "Error fetching data: #{e.message}"
40
+ Jekyll.logger.warn 'NotionCMS:', 'Falling back to collections'
41
+ use_all_collections_fallback
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # Fetch data for a single collection
48
+ def fetch_collection_data(collection_name, config)
49
+ env_var = config['database_env']
50
+ data_file = config['data_file']
51
+
52
+ database_id = ENV.fetch(env_var, nil)
53
+ if database_id.nil? || database_id.empty? || database_id.start_with?('example_')
54
+ Jekyll.logger.info 'NotionCMS:', "No #{env_var} found, using fallback for #{collection_name}"
55
+ use_collection_fallback(collection_name, config)
56
+ return
57
+ end
58
+
59
+ begin
60
+ notion_data = @client.query_database(database_id)
61
+ organized_data = DataOrganizers.organize(notion_data, config)
62
+
63
+ if DataOrganizers.data_present?(organized_data)
64
+ data_key = data_file.sub('.yml', '').sub('.yaml', '')
65
+ @site.data[data_key] = organized_data
66
+ create_data_file(organized_data, data_file, collection_name)
67
+
68
+ count = organized_data.is_a?(Hash) ? organized_data.size : organized_data.length
69
+ Jekyll.logger.info 'NotionCMS:', "#{collection_name} fetched (#{count} items)"
70
+ else
71
+ Jekyll.logger.warn 'NotionCMS:', "No data found for #{collection_name}, using fallback"
72
+ use_collection_fallback(collection_name, config)
73
+ end
74
+ rescue StandardError => e
75
+ Jekyll.logger.error 'NotionCMS:', "Error fetching #{collection_name}: #{e.message}"
76
+ use_collection_fallback(collection_name, config)
77
+ end
78
+ end
79
+
80
+ # Create a YAML data file
81
+ def create_data_file(data, file_name, collection_name)
82
+ data_dir = File.join(@site.source, '_data')
83
+ FileUtils.mkdir_p(data_dir)
84
+
85
+ data_file = File.join(data_dir, file_name)
86
+ new_content = data.to_yaml
87
+
88
+ # Skip if content unchanged
89
+ if File.exist?(data_file)
90
+ existing_content = File.read(data_file)
91
+ yaml_start = existing_content.index("---\n")
92
+ if yaml_start
93
+ existing_yaml = existing_content[yaml_start..]
94
+ if existing_yaml.strip == new_content.strip
95
+ Jekyll.logger.info 'NotionCMS:', "#{collection_name} data unchanged, skipping"
96
+ return
97
+ end
98
+ end
99
+ end
100
+
101
+ File.open(data_file, 'w') do |file|
102
+ file.write("# #{collection_name.capitalize} data imported from Notion\n")
103
+ file.write("# Auto-generated by jekyll-notion-cms - Do not edit manually\n")
104
+ file.write("# Last updated: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
105
+ file.write(new_content)
106
+ end
107
+
108
+ Jekyll.logger.info 'NotionCMS:', "#{collection_name} written to _data/#{file_name}"
109
+ end
110
+
111
+ # Use fallback for all collections
112
+ def use_all_collections_fallback
113
+ @collections_config.each do |collection_name, config|
114
+ use_collection_fallback(collection_name, config)
115
+ end
116
+ end
117
+
118
+ # Use Jekyll collections as fallback
119
+ def use_collection_fallback(collection_name, config)
120
+ Jekyll.logger.info 'NotionCMS:', "Using fallback for #{collection_name}"
121
+
122
+ data_file = config['data_file']
123
+ data_key = data_file.sub('.yml', '').sub('.yaml', '')
124
+ properties_config = config['properties'] || []
125
+
126
+ # Convert Jekyll collection to Notion-like format
127
+ mock_data = { 'results' => [] }
128
+
129
+ @site.collections[collection_name]&.docs&.each_with_index do |doc, index|
130
+ mock_data['results'] << {
131
+ 'id' => "collection_#{index}",
132
+ 'properties' => convert_doc_to_properties(doc.data, properties_config),
133
+ 'created_time' => doc.data['date']&.to_s,
134
+ 'last_edited_time' => doc.data['last_modified']&.to_s
135
+ }
136
+ end
137
+
138
+ organized_data = DataOrganizers.organize(mock_data, config)
139
+ @site.data[data_key] = organized_data
140
+ create_data_file(organized_data, data_file, collection_name)
141
+
142
+ count = organized_data.is_a?(Hash) ? organized_data.size : organized_data.length
143
+ Jekyll.logger.info 'NotionCMS:', "#{collection_name} fallback applied (#{count} items)"
144
+ end
145
+
146
+ # Convert Jekyll document data to Notion-like properties
147
+ def convert_doc_to_properties(data, properties_config)
148
+ properties = {}
149
+
150
+ properties_config.each do |prop_config|
151
+ prop_name = prop_config['name']
152
+ prop_type = prop_config['type']
153
+ prop_key = prop_config['key'] || PropertyExtractors.normalize_key(prop_name)
154
+
155
+ value = data[prop_key] || data[prop_name.downcase] || data[prop_name]
156
+ next if value.nil?
157
+
158
+ properties[prop_name] = convert_value_to_notion_property(value, prop_type)
159
+ end
160
+
161
+ properties
162
+ end
163
+
164
+ # Convert a value to Notion property format
165
+ def convert_value_to_notion_property(value, prop_type)
166
+ case prop_type
167
+ when 'title'
168
+ { 'type' => 'title', 'title' => [{ 'plain_text' => value.to_s }] }
169
+ when 'rich_text'
170
+ { 'type' => 'rich_text', 'rich_text' => [{ 'plain_text' => value.to_s }] }
171
+ when 'number'
172
+ { 'type' => 'number', 'number' => value.to_i }
173
+ when 'checkbox'
174
+ { 'type' => 'checkbox', 'checkbox' => !!value }
175
+ when 'date'
176
+ { 'type' => 'date', 'date' => { 'start' => value.to_s } }
177
+ when 'select'
178
+ { 'type' => 'select', 'select' => { 'name' => value.to_s } }
179
+ when 'multi_select'
180
+ items = value.is_a?(Array) ? value : [value]
181
+ { 'type' => 'multi_select', 'multi_select' => items.map { |v| { 'name' => v.to_s } } }
182
+ when 'url'
183
+ { 'type' => 'url', 'url' => value.to_s }
184
+ else
185
+ { 'type' => 'rich_text', 'rich_text' => [{ 'plain_text' => value.to_s }] }
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module JekyllNotionCMS
8
+ # Client for interacting with the Notion API
9
+ class NotionClient
10
+ NOTION_API_VERSION = '2022-06-28'
11
+ NOTION_API_BASE_URL = 'https://api.notion.com/v1'
12
+
13
+ def initialize(token)
14
+ @token = token
15
+ raise ConfigurationError, 'Notion token is required' if @token.nil? || @token.empty?
16
+ end
17
+
18
+ # Query a Notion database and return all results
19
+ # @param database_id [String] The ID of the database to query
20
+ # @param filter [Hash] Optional filter for the query
21
+ # @param sorts [Array] Optional sorts for the query
22
+ # @return [Hash] The API response with results
23
+ def query_database(database_id, filter: nil, sorts: nil)
24
+ all_results = []
25
+ start_cursor = nil
26
+
27
+ loop do
28
+ response = query_database_page(database_id, filter: filter, sorts: sorts, start_cursor: start_cursor)
29
+ all_results.concat(response['results'])
30
+
31
+ break unless response['has_more']
32
+
33
+ start_cursor = response['next_cursor']
34
+ end
35
+
36
+ { 'results' => all_results }
37
+ end
38
+
39
+ # Retrieve a single page
40
+ # @param page_id [String] The ID of the page to retrieve
41
+ # @return [Hash] The page data
42
+ def get_page(page_id)
43
+ uri = URI("#{NOTION_API_BASE_URL}/pages/#{page_id}")
44
+ request = build_request(:get, uri)
45
+ execute_request(uri, request)
46
+ end
47
+
48
+ # Retrieve page content (blocks)
49
+ # @param page_id [String] The ID of the page
50
+ # @return [Hash] The blocks data
51
+ def get_page_content(page_id)
52
+ all_results = []
53
+ start_cursor = nil
54
+
55
+ loop do
56
+ uri = URI("#{NOTION_API_BASE_URL}/blocks/#{page_id}/children")
57
+ uri.query = URI.encode_www_form({ start_cursor: start_cursor, page_size: 100 }.compact)
58
+
59
+ request = build_request(:get, uri)
60
+ response = execute_request(uri, request)
61
+
62
+ all_results.concat(response['results'])
63
+
64
+ break unless response['has_more']
65
+
66
+ start_cursor = response['next_cursor']
67
+ end
68
+
69
+ { 'results' => all_results }
70
+ end
71
+
72
+ private
73
+
74
+ def query_database_page(database_id, filter: nil, sorts: nil, start_cursor: nil)
75
+ uri = URI("#{NOTION_API_BASE_URL}/databases/#{database_id}/query")
76
+
77
+ body = { page_size: 100 }
78
+ body[:filter] = filter if filter
79
+ body[:sorts] = sorts if sorts
80
+ body[:start_cursor] = start_cursor if start_cursor
81
+
82
+ request = build_request(:post, uri)
83
+ request.body = body.to_json
84
+
85
+ execute_request(uri, request)
86
+ end
87
+
88
+ def build_request(method, uri)
89
+ request_class = case method
90
+ when :get then Net::HTTP::Get
91
+ when :post then Net::HTTP::Post
92
+ else raise ArgumentError, "Unknown method: #{method}"
93
+ end
94
+
95
+ request = request_class.new(uri)
96
+ request['Authorization'] = "Bearer #{@token}"
97
+ request['Notion-Version'] = NOTION_API_VERSION
98
+ request['Content-Type'] = 'application/json'
99
+ request
100
+ end
101
+
102
+ def execute_request(uri, request)
103
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
104
+ http.request(request)
105
+ end
106
+
107
+ handle_response(response)
108
+ end
109
+
110
+ def handle_response(response)
111
+ case response
112
+ when Net::HTTPSuccess
113
+ JSON.parse(response.body)
114
+ when Net::HTTPUnauthorized
115
+ raise APIError, 'Invalid Notion token (401 Unauthorized)'
116
+ when Net::HTTPNotFound
117
+ raise APIError, 'Database or page not found (404 Not Found)'
118
+ when Net::HTTPTooManyRequests
119
+ raise APIError, 'Rate limit exceeded (429 Too Many Requests)'
120
+ else
121
+ error_message = parse_error_message(response)
122
+ raise APIError, "Notion API error: #{response.code} #{error_message}"
123
+ end
124
+ end
125
+
126
+ def parse_error_message(response)
127
+ error_json = JSON.parse(response.body)
128
+ error_json['message'] || response.message
129
+ rescue JSON::ParserError
130
+ response.message
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllNotionCMS
4
+ # Module for extracting values from Notion property types
5
+ module PropertyExtractors
6
+ module_function
7
+
8
+ # Extract a property value based on its type
9
+ # @param properties [Hash] The properties hash from a Notion page
10
+ # @param property_name [String] The name of the property
11
+ # @param property_type [String] The expected type of the property
12
+ # @return [Object] The extracted value
13
+ def extract(properties, property_name, property_type)
14
+ property = properties[property_name]
15
+ return nil unless property
16
+
17
+ case property_type
18
+ when 'title'
19
+ extract_title(property)
20
+ when 'rich_text'
21
+ extract_rich_text(property)
22
+ when 'number'
23
+ extract_number(property)
24
+ when 'checkbox'
25
+ extract_checkbox(property)
26
+ when 'date'
27
+ extract_date(property)
28
+ when 'select'
29
+ extract_select(property)
30
+ when 'multi_select'
31
+ extract_multi_select(property)
32
+ when 'url'
33
+ extract_url(property)
34
+ when 'email'
35
+ extract_email(property)
36
+ when 'phone_number'
37
+ extract_phone_number(property)
38
+ when 'rollup'
39
+ extract_rollup(property)
40
+ when 'formula'
41
+ extract_formula(property)
42
+ when 'formula_array'
43
+ extract_formula_array(property)
44
+ when 'relation'
45
+ extract_relation(property)
46
+ when 'people'
47
+ extract_people(property)
48
+ when 'files'
49
+ extract_files(property)
50
+ when 'created_time'
51
+ extract_created_time(property)
52
+ when 'last_edited_time'
53
+ extract_last_edited_time(property)
54
+ when 'status'
55
+ extract_status(property)
56
+ end
57
+ end
58
+
59
+ # Extract all properties from a Notion page based on configuration
60
+ # @param properties [Hash] The properties hash from a Notion page
61
+ # @param properties_config [Array<Hash>] Configuration for each property
62
+ # @return [Hash] Extracted properties with normalized keys
63
+ def extract_all(properties, properties_config)
64
+ item = {}
65
+
66
+ properties_config.each do |prop_config|
67
+ prop_name = prop_config['name']
68
+ prop_type = prop_config['type']
69
+ prop_key = prop_config['key'] || normalize_key(prop_name)
70
+
71
+ value = extract(properties, prop_name, prop_type)
72
+ item[prop_key] = value
73
+ end
74
+
75
+ # Use 'title' as the main identifier, fall back to 'name'
76
+ item['title'] ||= item['name']
77
+
78
+ item
79
+ end
80
+
81
+ # Normalize a property name to a valid key
82
+ # @param name [String] The property name
83
+ # @return [String] The normalized key
84
+ def normalize_key(name)
85
+ name.downcase.gsub(/\s+/, '_')
86
+ end
87
+
88
+ # Title property
89
+ def extract_title(property)
90
+ return nil unless property['type'] == 'title'
91
+
92
+ property['title'].map { |text| text['plain_text'] }.join
93
+ end
94
+
95
+ # Rich text property
96
+ def extract_rich_text(property)
97
+ return nil unless property['type'] == 'rich_text'
98
+ return nil if property['rich_text'].nil? || property['rich_text'].empty?
99
+
100
+ property['rich_text'].map { |text| text['plain_text'] }.join
101
+ end
102
+
103
+ # Number property (also handles select-to-number conversion)
104
+ def extract_number(property)
105
+ case property['type']
106
+ when 'number'
107
+ property['number']
108
+ when 'select'
109
+ convert_select_to_number(property['select']&.dig('name'))
110
+ end
111
+ end
112
+
113
+ # Convert common select values to numbers
114
+ def convert_select_to_number(value)
115
+ case value
116
+ when 'Expert', 'Avancé', 'Advanced' then 90
117
+ when 'Intermédiaire', 'Intermediate' then 70
118
+ when 'Débutant', 'Beginner' then 50
119
+ end
120
+ end
121
+
122
+ # Checkbox property
123
+ def extract_checkbox(property)
124
+ property['type'] == 'checkbox' ? property['checkbox'] : false
125
+ end
126
+
127
+ # Date property
128
+ def extract_date(property)
129
+ return nil unless property['type'] == 'date'
130
+ return nil if property['date'].nil?
131
+
132
+ {
133
+ 'start' => property['date']['start'],
134
+ 'end' => property['date']['end'],
135
+ 'time_zone' => property['date']['time_zone']
136
+ }.compact
137
+ end
138
+
139
+ # Select property
140
+ def extract_select(property)
141
+ return nil unless property['type'] == 'select'
142
+
143
+ property['select']&.dig('name')
144
+ end
145
+
146
+ # Multi-select property
147
+ def extract_multi_select(property)
148
+ return [] unless property['type'] == 'multi_select'
149
+
150
+ property['multi_select'].map { |item| item['name'] }
151
+ end
152
+
153
+ # URL property (also handles rich_text containing URLs)
154
+ def extract_url(property)
155
+ case property['type']
156
+ when 'url'
157
+ property['url']
158
+ when 'rich_text'
159
+ extract_rich_text(property)
160
+ end
161
+ end
162
+
163
+ # Email property
164
+ def extract_email(property)
165
+ return nil unless property['type'] == 'email'
166
+
167
+ property['email']
168
+ end
169
+
170
+ # Phone number property
171
+ def extract_phone_number(property)
172
+ return nil unless property['type'] == 'phone_number'
173
+
174
+ property['phone_number']
175
+ end
176
+
177
+ # Rollup property
178
+ def extract_rollup(property)
179
+ return nil unless property['type'] == 'rollup'
180
+ return nil unless property['rollup']
181
+
182
+ rollup = property['rollup']
183
+
184
+ case rollup['type']
185
+ when 'array'
186
+ extract_rollup_array(rollup['array'])
187
+ when 'number'
188
+ rollup['number']
189
+ when 'date'
190
+ rollup['date']&.dig('start')
191
+ end
192
+ end
193
+
194
+ # Extract first value from rollup array
195
+ def extract_rollup_array(array)
196
+ return nil if array.nil? || array.empty?
197
+
198
+ array.map do |item|
199
+ case item['type']
200
+ when 'title'
201
+ item['title'].map { |text| text['plain_text'] }.join
202
+ when 'rich_text'
203
+ item['rich_text'].map { |text| text['plain_text'] }.join
204
+ when 'select'
205
+ item['select']&.dig('name')
206
+ when 'number'
207
+ item['number']
208
+ end
209
+ end.compact.first
210
+ end
211
+
212
+ # Formula property
213
+ def extract_formula(property)
214
+ return nil unless property['type'] == 'formula'
215
+ return nil if property['formula'].nil?
216
+
217
+ formula = property['formula']
218
+
219
+ case formula['type']
220
+ when 'string'
221
+ formula['string']
222
+ when 'number'
223
+ formula['number']
224
+ when 'boolean'
225
+ formula['boolean']
226
+ when 'date'
227
+ formula['date']&.dig('start')
228
+ end
229
+ end
230
+
231
+ # Formula returning array
232
+ def extract_formula_array(property)
233
+ return [] unless property['type'] == 'formula'
234
+ return [] if property['formula'].nil?
235
+
236
+ formula = property['formula']
237
+
238
+ case formula['type']
239
+ when 'array'
240
+ extract_formula_array_items(formula['array'])
241
+ when 'string'
242
+ parse_formula_string(formula['string'])
243
+ else
244
+ []
245
+ end
246
+ end
247
+
248
+ # Extract items from formula array
249
+ def extract_formula_array_items(array)
250
+ return [] unless array
251
+
252
+ array.map do |item|
253
+ case item['type']
254
+ when 'string'
255
+ item['string']
256
+ when 'rich_text'
257
+ item['rich_text']&.map { |text| text['plain_text'] }&.join
258
+ end
259
+ end.compact
260
+ end
261
+
262
+ # Parse formula string to array (split by delimiter)
263
+ def parse_formula_string(string_value)
264
+ return [] if string_value.nil? || string_value.empty?
265
+
266
+ string_value.split('- ').map do |item|
267
+ cleaned = item.strip.gsub(/^\.+|\.+$/, '')
268
+ cleaned.empty? ? nil : cleaned
269
+ end.compact
270
+ end
271
+
272
+ # Relation property
273
+ def extract_relation(property)
274
+ return [] unless property['type'] == 'relation'
275
+ return [] if property['relation'].nil? || property['relation'].empty?
276
+
277
+ property['relation'].map { |relation| relation['id'] }
278
+ end
279
+
280
+ # People property
281
+ def extract_people(property)
282
+ return [] unless property['type'] == 'people'
283
+ return [] if property['people'].nil? || property['people'].empty?
284
+
285
+ property['people'].map do |person|
286
+ {
287
+ 'id' => person['id'],
288
+ 'name' => person['name'],
289
+ 'email' => person.dig('person', 'email'),
290
+ 'avatar_url' => person['avatar_url']
291
+ }.compact
292
+ end
293
+ end
294
+
295
+ # Files property
296
+ def extract_files(property)
297
+ return [] unless property['type'] == 'files'
298
+ return [] if property['files'].nil? || property['files'].empty?
299
+
300
+ property['files'].map do |file|
301
+ url = file.dig('file', 'url') || file.dig('external', 'url')
302
+ {
303
+ 'name' => file['name'],
304
+ 'url' => url,
305
+ 'type' => file['type']
306
+ }.compact
307
+ end
308
+ end
309
+
310
+ # Created time property
311
+ def extract_created_time(property)
312
+ return nil unless property['type'] == 'created_time'
313
+
314
+ property['created_time']
315
+ end
316
+
317
+ # Last edited time property
318
+ def extract_last_edited_time(property)
319
+ return nil unless property['type'] == 'last_edited_time'
320
+
321
+ property['last_edited_time']
322
+ end
323
+
324
+ # Status property
325
+ def extract_status(property)
326
+ return nil unless property['type'] == 'status'
327
+
328
+ property['status']&.dig('name')
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllNotionCMS
4
+ VERSION = '1.0.0'
5
+ end