jekyll-baserow-headless-cms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ name: Baserow Sync Workflow
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ baserow_event:
7
+ description: 'Type of Baserow event'
8
+ required: false
9
+ type: string
10
+ default: 'webhook'
11
+ row_id:
12
+ description: 'Baserow row ID'
13
+ required: false
14
+ type: string
15
+ table_id:
16
+ description: 'Baserow table ID'
17
+ required: false
18
+ type: string
19
+ default: 'all'
20
+
21
+ permissions:
22
+ contents: read
23
+ pages: write
24
+ id-token: write
25
+
26
+ jobs:
27
+ build:
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - name: Checkout
31
+ uses: actions/checkout@v4
32
+
33
+ - name: Setup Ruby
34
+ uses: ruby/setup-ruby@v1
35
+ with:
36
+ ruby-version: '3.3'
37
+ bundler-cache: true
38
+
39
+ - name: Build with Jekyll
40
+ run: bundle exec jekyll build
41
+ env:
42
+ JEKYLL_ENV: production
43
+ BASEROW_TOKEN: ${{ secrets.BASEROW_TOKEN }}
44
+ BASEROW_SKILLS_TABLE: ${{ secrets.BASEROW_SKILLS_TABLE }}
45
+ BASEROW_EXPERIENCES_TABLE: ${{ secrets.BASEROW_EXPERIENCES_TABLE }}
46
+ BASEROW_BLOG_TABLE: ${{ secrets.BASEROW_BLOG_TABLE }}
47
+ # Add more table secrets as needed
48
+
49
+ - name: Upload artifact
50
+ uses: actions/upload-pages-artifact@v3
51
+
52
+ deploy:
53
+ environment:
54
+ name: github-pages
55
+ url: ${{ steps.deployment.outputs.page_url }}
56
+ runs-on: ubuntu-latest
57
+ needs: build
58
+ steps:
59
+ - name: Deploy to GitHub Pages
60
+ id: deployment
61
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jekyll'
4
+ require_relative 'jekyll_baserow_headless_cms/version'
5
+ require_relative 'jekyll_baserow_headless_cms/baserow_client'
6
+ require_relative 'jekyll_baserow_headless_cms/field_extractors'
7
+ require_relative 'jekyll_baserow_headless_cms/data_organizers'
8
+ require_relative 'jekyll_baserow_headless_cms/generator'
9
+
10
+ module JekyllBaserowHeadlessCMS
11
+ class Error < StandardError; end
12
+ class ConfigurationError < Error; end
13
+ class APIError < Error; end
14
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module JekyllBaserowHeadlessCMS
8
+ # Client for interacting with the Baserow REST API
9
+ class BaserowClient
10
+ DEFAULT_API_BASE_URL = 'https://api.baserow.io'
11
+ ROW_PAGE_SIZE = 200
12
+
13
+ def initialize(token, base_url: nil)
14
+ @token = token
15
+ raise ConfigurationError, 'Baserow token is required' if @token.nil? || @token.empty?
16
+
17
+ @base_url = (base_url && !base_url.empty? ? base_url : DEFAULT_API_BASE_URL).chomp('/')
18
+ end
19
+
20
+ # Query a Baserow table and return all rows
21
+ # @param table_id [String, Integer] The ID of the table to query
22
+ # @param order_by [String] Optional Baserow order_by expression (e.g. "field_name" or "-field_name")
23
+ # @param filters [String] Optional Baserow filters expression (raw query string value)
24
+ # @return [Hash] The API response with results
25
+ def query_table(table_id, order_by: nil, filters: nil)
26
+ all_results = []
27
+ uri = build_rows_uri(table_id, order_by: order_by, filters: filters)
28
+
29
+ loop do
30
+ response = execute_request(uri, build_request(:get, uri))
31
+ all_results.concat(response['results'])
32
+
33
+ break unless response['next']
34
+
35
+ uri = URI(response['next'])
36
+ end
37
+
38
+ { 'results' => all_results }
39
+ end
40
+
41
+ # Retrieve a single row
42
+ # @param table_id [String, Integer] The ID of the table
43
+ # @param row_id [String, Integer] The ID of the row to retrieve
44
+ # @return [Hash] The row data
45
+ def get_row(table_id, row_id)
46
+ uri = URI("#{@base_url}/api/database/rows/table/#{table_id}/#{row_id}/")
47
+ uri.query = URI.encode_www_form({ user_field_names: true })
48
+
49
+ execute_request(uri, build_request(:get, uri))
50
+ end
51
+
52
+ private
53
+
54
+ def build_rows_uri(table_id, order_by:, filters:)
55
+ uri = URI("#{@base_url}/api/database/rows/table/#{table_id}/")
56
+ params = { user_field_names: true, size: ROW_PAGE_SIZE }
57
+ params[:order_by] = order_by if order_by
58
+ params[:filters] = filters if filters
59
+ uri.query = URI.encode_www_form(params)
60
+ uri
61
+ end
62
+
63
+ def build_request(method, uri)
64
+ request_class = case method
65
+ when :get then Net::HTTP::Get
66
+ else raise ArgumentError, "Unknown method: #{method}"
67
+ end
68
+
69
+ request = request_class.new(uri)
70
+ request['Authorization'] = "Token #{@token}"
71
+ request['Content-Type'] = 'application/json'
72
+ request
73
+ end
74
+
75
+ def execute_request(uri, request)
76
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
77
+ http.request(request)
78
+ end
79
+
80
+ handle_response(response)
81
+ end
82
+
83
+ def handle_response(response)
84
+ case response
85
+ when Net::HTTPSuccess
86
+ JSON.parse(response.body)
87
+ when Net::HTTPUnauthorized
88
+ raise APIError, 'Invalid Baserow token (401 Unauthorized)'
89
+ when Net::HTTPNotFound
90
+ raise APIError, 'Table or row not found (404 Not Found)'
91
+ when Net::HTTPTooManyRequests
92
+ raise APIError, 'Rate limit exceeded (429 Too Many Requests)'
93
+ else
94
+ error_message = parse_error_message(response)
95
+ raise APIError, "Baserow API error: #{response.code} #{error_message}"
96
+ end
97
+ end
98
+
99
+ def parse_error_message(response)
100
+ error_json = JSON.parse(response.body)
101
+ error_json['detail'] || error_json['error'] || response.message
102
+ rescue JSON::ParserError
103
+ response.message
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllBaserowHeadlessCMS
4
+ # Module for organizing Baserow data into different structures
5
+ module DataOrganizers
6
+ module_function
7
+
8
+ # Organize data based on the specified organizer type
9
+ # @param baserow_data [Hash] Raw data from the Baserow API (rows under 'results')
10
+ # @param config [Hash] Collection configuration
11
+ # @return [Hash, Array] Organized data
12
+ def organize(baserow_data, config)
13
+ organizer = config['organizer'] || 'simple_list'
14
+ fields_config = config['fields'] || []
15
+ sort_by = config['sort_by']
16
+ sort_order = config['sort_order'] || 'asc'
17
+
18
+ case organizer
19
+ when 'simple_list'
20
+ organize_simple_list(baserow_data, fields_config, sort_by, sort_order)
21
+ when 'items_by_category'
22
+ organize_items_by_category(baserow_data, fields_config)
23
+ when 'grouped_by'
24
+ group_field = config['group_by']
25
+ organize_grouped_by(baserow_data, fields_config, group_field, sort_by, sort_order)
26
+ when 'nested'
27
+ parent_field = config['parent_field'] || 'parent_id'
28
+ organize_nested(baserow_data, fields_config, parent_field, sort_by, sort_order)
29
+ else
30
+ organize_simple_list(baserow_data, fields_config, sort_by, sort_order)
31
+ end
32
+ end
33
+
34
+ # Organize as a simple sorted list
35
+ # @param baserow_data [Hash] Raw data from the Baserow API
36
+ # @param fields_config [Array<Hash>] Field configuration
37
+ # @param sort_by [String] Field to sort by
38
+ # @param sort_order [String] Sort order ('asc' or 'desc')
39
+ # @return [Array<Hash>] Sorted list of items
40
+ def organize_simple_list(baserow_data, fields_config, sort_by, sort_order)
41
+ items = baserow_data['results'].map do |row|
42
+ item = FieldExtractors.extract_all(row, fields_config)
43
+ item['id'] = row['id']
44
+ item
45
+ end
46
+
47
+ # Filter out items without title
48
+ items = items.select { |item| item['title'] && !item['title'].to_s.empty? }
49
+
50
+ # Sort if sort_by is specified
51
+ sort_items(items, sort_by, sort_order)
52
+ end
53
+
54
+ # Organize items grouped by category
55
+ # Useful for skills, products, team members, or any items with category grouping
56
+ # @param baserow_data [Hash] Raw data from the Baserow API
57
+ # @param fields_config [Array<Hash>] Field configuration
58
+ # @return [Hash] Items grouped by category
59
+ def organize_items_by_category(baserow_data, _fields_config)
60
+ items_by_category = {}
61
+
62
+ baserow_data['results'].each do |row|
63
+ process_category_row(row, items_by_category)
64
+ end
65
+
66
+ sort_categories_and_items(items_by_category)
67
+ end
68
+
69
+ # Process a single row for category organization
70
+ def process_category_row(row, items_by_category)
71
+ name = FieldExtractors.extract(row, 'Name', 'text')
72
+ return if name.nil? || name.empty?
73
+
74
+ category_data = extract_category_data(row)
75
+ category_name = category_data[:name]
76
+
77
+ items_by_category[category_name] ||= build_category_hash(category_data)
78
+ items_by_category[category_name]['items'] << build_category_item(row, name, category_data)
79
+ end
80
+
81
+ # Extract category-related data from a row (via lookup fields, Baserow's rollup equivalent)
82
+ def extract_category_data(row)
83
+ {
84
+ name: FieldExtractors.extract(row, 'Category', 'lookup') || 'Other',
85
+ icon: FieldExtractors.extract(row, 'Icon', 'lookup'),
86
+ color: FieldExtractors.extract(row, 'Color', 'lookup'),
87
+ order: FieldExtractors.extract(row, 'Category Order', 'lookup')
88
+ }
89
+ end
90
+
91
+ # Build the category hash structure
92
+ def build_category_hash(category_data)
93
+ order = category_data[:order]
94
+ {
95
+ 'title' => category_data[:name],
96
+ 'category' => category_data[:name],
97
+ 'subcategory' => nil,
98
+ 'icon' => category_data[:icon],
99
+ 'order' => (order.is_a?(Array) ? order.first : order) || 999,
100
+ 'items' => []
101
+ }
102
+ end
103
+
104
+ # Build an item hash for category organization
105
+ def build_category_item(row, name, category_data)
106
+ {
107
+ 'name' => name,
108
+ 'level' => FieldExtractors.extract(row, 'Level', 'number'),
109
+ 'years' => FieldExtractors.extract(row, 'Years', 'number'),
110
+ 'description' => nil,
111
+ 'icon' => nil,
112
+ 'color' => category_data[:color],
113
+ 'featured' => FieldExtractors.extract(row, 'Featured', 'boolean'),
114
+ 'order' => FieldExtractors.extract(row, 'Order', 'number') || 999,
115
+ 'id' => row['id']
116
+ }
117
+ end
118
+
119
+ # Sort categories and their items
120
+ def sort_categories_and_items(items_by_category)
121
+ sorted = items_by_category.sort_by do |_, data|
122
+ order = data['order']
123
+ (order.is_a?(Array) ? order.first : order).to_i
124
+ end.to_h
125
+
126
+ sorted.each_value do |data|
127
+ data['items'].sort_by! { |item| item['order'].to_i }
128
+ end
129
+
130
+ sorted
131
+ end
132
+
133
+ # Organize items grouped by a field
134
+ # @param baserow_data [Hash] Raw data from the Baserow API
135
+ # @param fields_config [Array<Hash>] Field configuration
136
+ # @param group_field [String] Field to group by
137
+ # @param sort_by [String] Field to sort by within groups
138
+ # @param sort_order [String] Sort order
139
+ # @return [Hash] Items grouped by field
140
+ def organize_grouped_by(baserow_data, fields_config, group_field, sort_by, sort_order)
141
+ grouped = {}
142
+
143
+ baserow_data['results'].each do |row|
144
+ item = FieldExtractors.extract_all(row, fields_config)
145
+ item['id'] = row['id']
146
+
147
+ next if item['title'].nil? || item['title'].to_s.empty?
148
+
149
+ group_key = item[group_field]
150
+ group_key = group_key.first if group_key.is_a?(Array)
151
+ group_key ||= 'Other'
152
+
153
+ grouped[group_key] ||= []
154
+ grouped[group_key] << item
155
+ end
156
+
157
+ # Sort within groups
158
+ grouped.each_value do |items|
159
+ sort_items(items, sort_by, sort_order)
160
+ end
161
+
162
+ grouped
163
+ end
164
+
165
+ # Organize items in a nested tree structure
166
+ # @param baserow_data [Hash] Raw data from the Baserow API
167
+ # @param fields_config [Array<Hash>] Field configuration
168
+ # @param parent_field [String] Field containing parent reference
169
+ # @param sort_by [String] Field to sort by
170
+ # @param sort_order [String] Sort order
171
+ # @return [Array<Hash>] Nested tree of items
172
+ def organize_nested(baserow_data, fields_config, parent_field, sort_by, sort_order)
173
+ items = {}
174
+ roots = []
175
+
176
+ # First pass: extract all items
177
+ baserow_data['results'].each do |row|
178
+ item = FieldExtractors.extract_all(row, fields_config)
179
+ item['id'] = row['id']
180
+ item['children'] = []
181
+ items[row['id']] = item
182
+ end
183
+
184
+ # Second pass: build tree structure
185
+ items.each_value do |item|
186
+ parent_ids = item[parent_field]
187
+ parent_id = parent_ids.is_a?(Array) ? parent_ids.first : parent_ids
188
+
189
+ if parent_id && items[parent_id]
190
+ items[parent_id]['children'] << item
191
+ else
192
+ roots << item
193
+ end
194
+ end
195
+
196
+ # Sort at each level
197
+ sort_nested(roots, sort_by, sort_order)
198
+
199
+ roots
200
+ end
201
+
202
+ # Sort items by field
203
+ # @param items [Array<Hash>] Items to sort
204
+ # @param sort_by [String] Field to sort by
205
+ # @param sort_order [String] Sort order ('asc' or 'desc')
206
+ # @return [Array<Hash>] Sorted items
207
+ def sort_items(items, sort_by, sort_order)
208
+ return items unless sort_by && !sort_by.empty?
209
+
210
+ items.sort_by! do |item|
211
+ value = item[sort_by]
212
+ case value
213
+ when nil then sort_order == 'desc' ? -Float::INFINITY : Float::INFINITY
214
+ when Numeric then value
215
+ when String then value.downcase
216
+ else value.to_s.downcase
217
+ end
218
+ end
219
+
220
+ items.reverse! if sort_order == 'desc'
221
+ items
222
+ end
223
+
224
+ # Recursively sort nested items
225
+ # @param items [Array<Hash>] Items to sort
226
+ # @param sort_by [String] Field to sort by
227
+ # @param sort_order [String] Sort order
228
+ def sort_nested(items, sort_by, sort_order)
229
+ sort_items(items, sort_by, sort_order)
230
+
231
+ items.each do |item|
232
+ sort_nested(item['children'], sort_by, sort_order) if item['children']&.any?
233
+ end
234
+ end
235
+
236
+ # Check if data is present (non-empty)
237
+ # @param data [Hash, Array] Data to check
238
+ # @return [Boolean] True if data is present
239
+ def data_present?(data)
240
+ return false if data.nil?
241
+
242
+ if data.is_a?(Hash)
243
+ data.size.positive?
244
+ else
245
+ data.length.positive?
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllBaserowHeadlessCMS
4
+ # Module for extracting values from Baserow field types
5
+ #
6
+ # Unlike Notion, Baserow returns row data as a flat hash (with
7
+ # `user_field_names=true`) where each key is already the field's value in
8
+ # its natural JSON shape. The declared +field_type+ tells us how to
9
+ # interpret that shape.
10
+ module FieldExtractors
11
+ module_function
12
+
13
+ # Extract a field value based on its type
14
+ # @param row [Hash] A row hash from the Baserow API
15
+ # @param field_name [String] The name of the field
16
+ # @param field_type [String] The expected type of the field
17
+ # @return [Object] The extracted value
18
+ def extract(row, field_name, field_type)
19
+ return nil unless row.key?(field_name)
20
+
21
+ value = row[field_name]
22
+
23
+ case field_type
24
+ when 'text', 'long_text'
25
+ extract_text(value)
26
+ when 'number', 'rating', 'count'
27
+ extract_number(value)
28
+ when 'boolean'
29
+ extract_boolean?(value)
30
+ when 'date', 'created_on', 'last_modified'
31
+ extract_date(value)
32
+ when 'single_select'
33
+ extract_single_select(value)
34
+ when 'multi_select'
35
+ extract_multi_select(value)
36
+ when 'url'
37
+ extract_url(value)
38
+ when 'email'
39
+ extract_email(value)
40
+ when 'phone_number'
41
+ extract_phone_number(value)
42
+ when 'link_row'
43
+ extract_link_row(value)
44
+ when 'lookup', 'formula_array'
45
+ extract_lookup(value)
46
+ when 'formula'
47
+ extract_formula(value)
48
+ when 'file'
49
+ extract_file(value)
50
+ end
51
+ end
52
+
53
+ # Extract all fields from a Baserow row based on configuration
54
+ # @param row [Hash] A row hash from the Baserow API
55
+ # @param fields_config [Array<Hash>] Configuration for each field
56
+ # @return [Hash] Extracted fields with normalized keys
57
+ def extract_all(row, fields_config)
58
+ item = {}
59
+
60
+ fields_config.each do |field_config|
61
+ field_name = field_config['name']
62
+ field_type = field_config['type']
63
+ field_key = field_config['key'] || normalize_key(field_name)
64
+
65
+ value = extract(row, field_name, field_type)
66
+ item[field_key] = value
67
+ end
68
+
69
+ # Use 'title' as the main identifier, fall back to 'name'
70
+ item['title'] ||= item['name']
71
+
72
+ item
73
+ end
74
+
75
+ # Normalize a field name to a valid key
76
+ # @param name [String] The field name
77
+ # @return [String] The normalized key
78
+ def normalize_key(name)
79
+ name.downcase.gsub(/\s+/, '_')
80
+ end
81
+
82
+ # Text / long text field
83
+ def extract_text(value)
84
+ return nil if value.nil? || value == ''
85
+
86
+ value.to_s
87
+ end
88
+
89
+ # Number / rating / count field (Baserow may return decimal numbers as strings)
90
+ def extract_number(value)
91
+ return nil if value.nil?
92
+
93
+ case value
94
+ when Numeric
95
+ value
96
+ when String
97
+ value.include?('.') ? value.to_f : value.to_i
98
+ end
99
+ end
100
+
101
+ # Boolean field
102
+ def extract_boolean?(value)
103
+ value == true
104
+ end
105
+
106
+ # Date / created on / last modified field (returned as an ISO 8601 string)
107
+ def extract_date(value)
108
+ return nil if value.nil? || value == ''
109
+
110
+ value.to_s
111
+ end
112
+
113
+ # Single select field: { "id" => 1, "value" => "Backend", "color" => "blue" }
114
+ def extract_single_select(value)
115
+ return nil unless value.is_a?(Hash)
116
+
117
+ value['value']
118
+ end
119
+
120
+ # Multiple select field: array of { "id" => .., "value" => .., "color" => .. }
121
+ def extract_multi_select(value)
122
+ return [] unless value.is_a?(Array)
123
+
124
+ value.map { |option| option['value'] }.compact
125
+ end
126
+
127
+ # URL field
128
+ def extract_url(value)
129
+ return nil if value.nil? || value == ''
130
+
131
+ value.to_s
132
+ end
133
+
134
+ # Email field
135
+ def extract_email(value)
136
+ return nil if value.nil? || value == ''
137
+
138
+ value.to_s
139
+ end
140
+
141
+ # Phone number field
142
+ def extract_phone_number(value)
143
+ return nil if value.nil? || value == ''
144
+
145
+ value.to_s
146
+ end
147
+
148
+ # Link to table field: array of { "id" => .., "value" => .. }
149
+ def extract_link_row(value)
150
+ return [] unless value.is_a?(Array)
151
+
152
+ value.map do |linked_row|
153
+ { 'id' => linked_row['id'], 'name' => linked_row['value'] }.compact
154
+ end
155
+ end
156
+
157
+ # Lookup field, or a formula field returning an array: array of { "id" => .., "value" => .. }
158
+ # Mirrors Notion rollups: returns a single value if only one, otherwise an array
159
+ def extract_lookup(value)
160
+ return nil unless value.is_a?(Array)
161
+
162
+ values = value.map { |item| item.is_a?(Hash) ? item['value'] : item }.compact
163
+ return nil if values.empty?
164
+
165
+ values.length == 1 ? values.first : values
166
+ end
167
+
168
+ # Formula field returning a scalar value (string, number, boolean or date)
169
+ def extract_formula(value)
170
+ value
171
+ end
172
+
173
+ # File field: array of { "url" => .., "name" => .., "mime_type" => .., ... }
174
+ def extract_file(value)
175
+ return [] unless value.is_a?(Array)
176
+
177
+ value.map do |file|
178
+ { 'name' => file['name'], 'url' => file['url'], 'type' => file['mime_type'] }.compact
179
+ end
180
+ end
181
+ end
182
+ end