bridgetown_directus 0.1.3 → 0.3.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.
@@ -1,103 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "fileutils"
5
+ require "yaml"
6
+
1
7
  module BridgetownDirectus
2
8
  class Builder < Bridgetown::Builder
3
9
  def build
10
+ config = site.config.bridgetown_directus
4
11
  return if site.ssr?
5
12
 
6
- Utils.log_directus "Connecting to Directus API..."
7
- posts_data = fetch_posts
13
+ config.collections.each_value do |collection_config|
14
+ next unless [:posts, :pages, :custom_collection].include?(collection_config.resource_type)
8
15
 
9
- Utils.log_directus "Fetched #{posts_data.size} posts from Directus."
10
-
11
- create_documents(posts_data)
16
+ process_collection(
17
+ client: Client.new(
18
+ api_url: config.api_url,
19
+ token: config.token
20
+ ),
21
+ collection_config: collection_config
22
+ )
23
+ end
12
24
  end
13
25
 
14
26
  private
15
27
 
16
- def fetch_posts
17
- api_client = BridgetownDirectus::APIClient.new(site)
18
- api_client.fetch_posts
28
+ # Determine the output directory for the given collection name
29
+ def collection_directory(collection_name)
30
+ case collection_name.to_s
31
+ when "posts"
32
+ File.join(site.source, "_posts")
33
+ when "pages"
34
+ File.join(site.source, "_pages")
35
+ else
36
+ File.join(site.source, "_#{collection_name}")
37
+ end
38
+ end
39
+
40
+ def build_directus_payload(item, collection_dir, collection_config, api_url = nil)
41
+ mapped_item = apply_data_mapping(item, collection_config)
42
+ slug = normalize_slug(mapped_item)
43
+ mapped_item["slug"] = slug
44
+ filename = build_filename(collection_dir, collection_config, mapped_item, slug)
45
+ mapped_item = transform_item_fields(mapped_item, api_url, collection_config.layout)
46
+ mapped_item["directus_generated"] = true # Add flag to front matter
47
+ content = mapped_item.delete("body") || ""
48
+ front_matter = generate_front_matter(mapped_item)
49
+ payload = render_markdown(front_matter, content)
50
+ [filename, payload]
19
51
  end
20
52
 
21
- def create_documents(posts_data)
22
- # Ensure posts_data contains a "data" key and it is an array
23
- if posts_data.is_a?(Hash) && posts_data.key?("data") && posts_data["data"].is_a?(Array)
24
- posts_array = posts_data["data"]
25
- elsif posts_data.is_a?(Array)
26
- posts_array = posts_data
53
+ def build_filename(collection_dir, collection_config, item, slug)
54
+ if collection_config.resource_type == :posts || collection_config.name.to_s == "posts"
55
+ post_date = extract_post_date(item)
56
+ if post_date
57
+ return File.join(collection_dir, "#{post_date.strftime("%Y-%m-%d")}-#{slug}.md")
58
+ end
59
+ end
60
+
61
+ File.join(collection_dir, "#{slug}.md")
62
+ end
63
+
64
+ def normalize_slug(item)
65
+ slug = item["slug"] || item[:slug]
66
+ slug = slug.to_s.strip
67
+ return slug unless slug.empty?
68
+
69
+ title = item["title"] || item[:title]
70
+ if title && defined?(Bridgetown::Utils) && Bridgetown::Utils.respond_to?(:slugify)
71
+ slug = Bridgetown::Utils.slugify(title.to_s)
27
72
  else
28
- raise "Unexpected structure of posts_data: #{posts_data.inspect}"
73
+ slug = title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
29
74
  end
30
75
 
31
- created_posts = 0
32
- posts_array.each do |post|
33
- if translations_enabled?
34
- created_posts += create_translated_posts(post)
35
- else
36
- created_posts += create_single_post(post)
37
- end
76
+ slug = slug.strip
77
+ return slug unless slug.empty?
78
+
79
+ id = item["id"] || item[:id]
80
+ id.to_s
81
+ end
82
+
83
+ def apply_data_mapping(item, collection_config)
84
+ mapped_item = item.dup
85
+ return mapped_item unless collection_config.fields.any? || collection_config.translations_enabled
86
+
87
+ mapped_fields = if collection_config.translations_enabled
88
+ DataMapper.map_translations(collection_config, item, resolve_locale)
89
+ else
90
+ DataMapper.map(collection_config, item)
91
+ end
92
+
93
+ mapped_item.merge!(mapped_fields)
94
+ mapped_item
95
+ end
96
+
97
+ def resolve_locale
98
+ return site.locale if site.respond_to?(:locale) && site.locale
99
+
100
+ config_locale = site.config["locale"] || site.config[:locale]
101
+ return config_locale.to_sym if config_locale
102
+
103
+ :en
104
+ end
105
+
106
+ def extract_post_date(item)
107
+ raw = item["date"] || item[:date] || item["published_at"] || item[:published_at] || item["date_created"]
108
+ item["date"] ||= item["published_at"] if item["published_at"] && !item["date"]
109
+ return nil unless raw
110
+
111
+ return raw.to_date if raw.respond_to?(:to_date)
112
+
113
+ Date.parse(raw.to_s)
114
+ rescue ArgumentError
115
+ nil
116
+ end
117
+
118
+ def transform_item_fields(item, api_url, layout)
119
+ item = item.dup
120
+ if item["image"] && api_url && !item["image"].to_s.start_with?("http://", "https://")
121
+ item["image"] = File.join(api_url, "assets", item["image"])
38
122
  end
123
+ item["layout"] = layout if layout
124
+ item
125
+ end
126
+
127
+ def generate_front_matter(item)
128
+ yaml = item.to_yaml
129
+ yaml.sub(%r{^---\s*\n}, "") # Remove leading --- if present
130
+ end
39
131
 
40
- Utils.log_directus "Finished generating #{created_posts} posts."
132
+ def render_markdown(front_matter, content)
133
+ "---\n#{front_matter}---\n\n#{content}"
41
134
  end
42
135
 
43
- def translations_enabled?
44
- site.config.dig("directus", "translations", "enabled") == true
136
+ def write_markdown_file(filename, payload)
137
+ FileUtils.mkdir_p(File.dirname(filename))
138
+ File.write(filename, payload)
139
+ end
140
+
141
+ def file_unchanged?(filename, payload)
142
+ return false unless File.exist?(filename)
143
+
144
+ File.read(filename) == payload
145
+ rescue StandardError
146
+ false
45
147
  end
46
148
 
47
- def create_single_post(post)
48
- slug = post["slug"] || Bridgetown::Utils.slugify(post["title"])
49
- api_url = site.config.dig("directus", "api_url")
149
+ # Remove only plugin-generated Markdown files in the target directory before writing new ones
150
+ def clean_collection_directory(collection_dir, keep_files: [])
151
+ keep_set = keep_files.map { |file| File.expand_path(file) }.to_h { |file| [file, true] }
50
152
 
153
+ deleted = 0
154
+ Dir.glob(File.join(collection_dir, "*.md")).each do |file|
155
+ next if keep_set[File.expand_path(file)]
156
+
157
+ fm = File.read(file)[%r{\A---.*?---}m]
158
+ if fm && YAML.safe_load(fm, permitted_classes: [Date, Time, DateTime])["directus_generated"]
159
+ File.delete(file)
160
+ deleted += 1
161
+ end
162
+ rescue StandardError => e
163
+ warn "[BridgetownDirectus] Could not check/delete #{file}: #{e.message}"
164
+ end
165
+
166
+ deleted
167
+ end
168
+
169
+ def process_collection(client:, collection_config:)
170
+ endpoint = collection_config.endpoint || collection_config.name.to_s
51
171
  begin
52
- add_resource :posts, "#{slug}.md" do
53
- layout "post"
54
- title post["title"]
55
- content post["body"]
56
- date post["date"] || Time.now.iso8601
57
- category post["category"]
58
- excerpt post["excerpt"]
59
- image post["image"] ? "#{api_url}/assets/#{post['image']}" : nil
172
+ response = client.fetch_collection(endpoint, collection_config.default_query)
173
+ rescue StandardError => e
174
+ warn "Error fetching collection '#{endpoint}': #{e.message}"
175
+ return
176
+ end
177
+ collection_dir = collection_directory(collection_config.name)
178
+ FileUtils.mkdir_p(collection_dir)
179
+ api_url = site.config.bridgetown_directus.api_url
180
+ sanitized_response = sanitize_keys(response)
181
+ payloads = sanitized_response.to_h do |item|
182
+ build_directus_payload(item, collection_dir, collection_config, api_url)
183
+ end
184
+ log_directus("Generating #{collection_label(collection_config)} (#{payloads.size} items)")
185
+ deleted = clean_collection_directory(collection_dir, keep_files: payloads.keys)
186
+ written = 0
187
+ skipped = 0
188
+ payloads.each do |filename, payload|
189
+ if file_unchanged?(filename, payload)
190
+ skipped += 1
191
+ next
60
192
  end
61
- 1
62
- rescue => e
63
- Utils.log_directus "Error creating post #{slug}: #{e.message}"
64
- 0
193
+
194
+ write_markdown_file(filename, payload)
195
+ written += 1
65
196
  end
197
+ log_directus("Updated #{collection_label(collection_config)}: wrote #{written}, skipped #{skipped}, deleted #{deleted}")
198
+ end
199
+
200
+ def log_directus(message)
201
+ return unless directus_logging_enabled?
202
+
203
+ Utils.log_directus(message)
204
+ end
205
+
206
+ def directus_logging_enabled?
207
+ flag = ENV["BRIDGETOWN_DIRECTUS_LOG"]
208
+ flag && !flag.to_s.strip.empty? && flag.to_s != "0"
66
209
  end
67
210
 
68
- def create_translated_posts(post)
69
- posts_created = 0
70
- translations = post["translations"] || []
71
-
72
- translations.each do |translation|
73
- lang_code = translation["languages_code"].split("-").first.downcase
74
- bridgetown_locale = lang_code.to_sym
75
-
76
- next unless site.config["available_locales"].include?(bridgetown_locale)
77
-
78
- slug = translation["slug"] || Bridgetown::Utils.slugify(translation["title"])
79
- api_url = site.config.dig("directus", "api_url")
80
-
81
- begin
82
- add_resource :posts, "#{slug}.md" do
83
- layout "post"
84
- title translation["title"]
85
- content translation["body"]
86
- date post["date"] || Time.now.iso8601
87
- category post["category"]
88
- excerpt translation["excerpt"]
89
- image post["image"] ? "#{api_url}/assets/#{post['image']}" : nil
90
- locale bridgetown_locale
91
- translations translations
92
- end
93
-
94
- posts_created += 1
95
- rescue => e
96
- Utils.log_directus "Error creating post #{slug} for locale #{bridgetown_locale}: #{e.message}"
211
+ def collection_label(collection_config)
212
+ collection_config.name.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
213
+ end
214
+
215
+ # Recursively sanitize keys to avoid illegal instance variable names (Ruby 3.4+)
216
+ def sanitize_keys(obj)
217
+ case obj
218
+ when Hash
219
+ obj.each_with_object({}) do |(k, v), h|
220
+ safe_key = if %r{^\d}.match?(k.to_s)
221
+ "n_#{k}"
222
+ else
223
+ k
224
+ end
225
+ h[safe_key] = sanitize_keys(v)
97
226
  end
227
+ when Array
228
+ obj.map { |v| sanitize_keys(v) }
229
+ else
230
+ obj
98
231
  end
232
+ end
99
233
 
100
- posts_created
234
+ # Recursively log all keys to find problematic ones
235
+ def log_all_keys(obj, path = "")
236
+ case obj
237
+ when Hash
238
+ obj.each do |k, v|
239
+ # puts "[BridgetownDirectus DEBUG] Key at #{path}: #{k.inspect}" if %r{^\d}.match?(k.to_s)
240
+ log_all_keys(v, "#{path}/#{k}")
241
+ end
242
+ when Array
243
+ obj.each_with_index do |v, idx|
244
+ log_all_keys(v, "#{path}[#{idx}]")
245
+ end
246
+ end
101
247
  end
102
248
  end
103
249
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+
6
+ module BridgetownDirectus
7
+ # Client for interacting with the Directus API
8
+ class Client
9
+ attr_reader :api_url, :token
10
+
11
+ def initialize(api_url:, token:)
12
+ @api_url = api_url
13
+ @token = token
14
+ return unless @token.nil? || @api_url.nil?
15
+
16
+ raise StandardError, "Invalid Directus configuration: missing API token or URL"
17
+ end
18
+
19
+ def fetch_collection(collection, params = {})
20
+ response = connection.get("/items/#{collection}") do |req|
21
+ req.params.merge!(prepare_params(params))
22
+ end
23
+ handle_response(response)
24
+ end
25
+
26
+ def fetch_item(collection, id, params = {})
27
+ response = connection.get("/items/#{collection}/#{id}") do |req|
28
+ req.params.merge!(prepare_params(params))
29
+ end
30
+ handle_response(response)
31
+ end
32
+
33
+ def fetch_items_with_filter(collection, filter, params = {})
34
+ merged_params = params.merge(filter: filter)
35
+ fetch_collection(collection, merged_params)
36
+ end
37
+
38
+ def fetch_related_items(collection, id, relation, params = {})
39
+ response = connection.get("/items/#{collection}/#{id}/#{relation}") do |req|
40
+ req.params.merge!(prepare_params(params))
41
+ end
42
+ handle_response(response)
43
+ end
44
+
45
+ private
46
+
47
+ def connection
48
+ @connection ||= Faraday.new(url: @api_url) do |faraday|
49
+ faraday.headers["Authorization"] = "Bearer #{@token}"
50
+ faraday.headers["Content-Type"] = "application/json"
51
+ faraday.adapter Faraday.default_adapter
52
+ end
53
+ end
54
+
55
+ def prepare_params(params)
56
+ params.transform_keys(&:to_s)
57
+ end
58
+
59
+ def handle_response(response)
60
+ unless response.success?
61
+ Utils.log_directus "Directus API error: #{response.status} - #{response.body}"
62
+ raise "Directus API error: #{response.status}"
63
+ end
64
+ json = JSON.parse(response.body)
65
+ json["data"] || []
66
+ rescue JSON::ParserError => e
67
+ Utils.log_directus "Failed to parse Directus response: #{e.message}"
68
+ raise
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BridgetownDirectus
4
+ # Configuration module for Bridgetown Directus plugin
5
+ class Configuration
6
+ attr_reader :collections
7
+ attr_accessor :api_url, :token
8
+
9
+ def initialize
10
+ @collections = {}
11
+ end
12
+
13
+ # Register a new collection with the given name
14
+ # @param name [Symbol] The name of the collection
15
+ # @param block [Proc] Configuration block for the collection
16
+ # @return [CollectionConfig] The collection configuration
17
+ def register_collection(name, &block)
18
+ collection = CollectionConfig.new(name)
19
+ collection.instance_eval(&block) if block_given?
20
+ @collections[name] = collection
21
+ collection
22
+ end
23
+
24
+ # Find a collection by name
25
+ # @param name [Symbol] The name of the collection
26
+ # @return [CollectionConfig, nil] The collection configuration or nil if not found
27
+ def find_collection(name)
28
+ @collections[name]
29
+ end
30
+
31
+ # Collection configuration class
32
+ class CollectionConfig
33
+ attr_reader :name
34
+
35
+ # Initialize a new collection configuration
36
+ # @param name [Symbol] The name of the collection
37
+ def initialize(name)
38
+ @name = name
39
+ @fields = {}
40
+ @default_query = {}
41
+ @resource_type = :posts
42
+ @layout = "post"
43
+ @translations_enabled = false
44
+ @translatable_fields = []
45
+ @endpoint = nil
46
+ end
47
+
48
+ # Set up accessors for collection configuration properties
49
+ attr_accessor :endpoint, :fields, :default_query, :resource_type, :layout,
50
+ :translations_enabled, :translatable_fields
51
+
52
+ # Define a field mapping with optional converter
53
+ # @param bridgetown_field [Symbol] The field name in Bridgetown
54
+ # @param directus_field [String, Symbol] The field name in Directus
55
+ # @param converter [Proc, nil] Optional converter to transform the field value
56
+ # @return [void]
57
+ def field(bridgetown_field, directus_field, &converter)
58
+ @fields[bridgetown_field] = {
59
+ directus_field: directus_field.to_s,
60
+ converter: converter,
61
+ }
62
+ end
63
+
64
+ # Enable translations for this collection
65
+ # @param fields [Array<Symbol>] The fields that should be translated
66
+ # @return [void]
67
+ def enable_translations(fields = [])
68
+ @translations_enabled = true
69
+ @translatable_fields = fields
70
+ end
71
+
72
+ # Generate the resource path for a given item
73
+ # @param item [Hash] The data item from Directus
74
+ # @return [String] The resource path
75
+ def path(item)
76
+ # Default: /:resource_type/:slug/index.html
77
+ slug = item["slug"] || item[:slug] || item["id"] || item[:id]
78
+ "/#{resource_type}/#{slug}/index.html"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BridgetownDirectus
4
+ # Data mapper for transforming Directus data into Bridgetown resources
5
+ class DataMapper
6
+ class << self
7
+ # Map Directus data to Bridgetown format based on collection configuration
8
+ # @param collection_config [CollectionConfig] The collection configuration
9
+ # @param data [Hash] The Directus data
10
+ # @return [Hash] The mapped data
11
+ def map(collection_config, data)
12
+ mapped_data = {}
13
+
14
+ collection_config.fields.each do |bridgetown_field, field_config|
15
+ if field_config.is_a?(Hash)
16
+ directus_field = field_config[:directus_field]
17
+ converter = field_config[:converter]
18
+
19
+ value = extract_value(data, directus_field)
20
+
21
+ # Apply converter if provided
22
+ value = converter.call(value) if converter.respond_to?(:call)
23
+
24
+ mapped_data[bridgetown_field] = value
25
+ else
26
+ # Support for simple string mapping for backward compatibility
27
+ directus_field = field_config.to_s
28
+ mapped_data[bridgetown_field] = extract_value(data, directus_field)
29
+ end
30
+ end
31
+
32
+ mapped_data
33
+ end
34
+
35
+ # Map translated fields from Directus data
36
+ # @param collection_config [CollectionConfig] The collection configuration
37
+ # @param data [Hash] The Directus data
38
+ # @param locale [Symbol] The locale to map
39
+ # @return [Hash] The mapped data with translations
40
+ def map_translations(collection_config, data, locale)
41
+ # First map the base data
42
+ mapped_data = map(collection_config, data)
43
+
44
+ # If translations are enabled and the data has translations
45
+ return mapped_data unless collection_config.translations_enabled && data["translations"]
46
+
47
+ # Find the translation for the requested locale
48
+ translation = find_translation_for_locale(data["translations"], locale)
49
+
50
+ # Apply translations if found
51
+ if translation
52
+ apply_translations(collection_config, translation, mapped_data)
53
+ mapped_data[:locale] = locale
54
+ end
55
+
56
+ mapped_data
57
+ end
58
+
59
+ # Resolve relationships in the data
60
+ # @param client [Client] The Directus client
61
+ # @param collection_config [CollectionConfig] The collection configuration
62
+ # @param data [Hash] The mapped data
63
+ # @param relationships [Hash] Relationship configuration
64
+ # @return [Hash] The data with resolved relationships
65
+ def resolve_relationships(client, collection_config, data, relationships)
66
+ return data unless relationships
67
+
68
+ resolved_data = data.dup
69
+
70
+ relationships.each do |field, relationship_config|
71
+ relation_id = data[field]
72
+ next unless relation_id
73
+
74
+ related_collection = relationship_config[:collection]
75
+ related_fields = relationship_config[:fields] || "*"
76
+
77
+ # Fetch the related item
78
+ related_item = client.fetch_item(
79
+ related_collection,
80
+ relation_id,
81
+ { fields: related_fields }
82
+ )
83
+
84
+ # Add the related data to the resolved data
85
+ if related_item && related_item["data"]
86
+ resolved_data["#{field}_data"] = related_item["data"]
87
+ end
88
+ end
89
+
90
+ resolved_data
91
+ end
92
+
93
+ private
94
+
95
+ # Find translation for a specific locale
96
+ # @param translations [Array] Array of translation objects
97
+ # @param locale [Symbol] The locale to find
98
+ # @return [Hash, nil] The translation for the locale or nil if not found
99
+ def find_translation_for_locale(translations, locale)
100
+ translations.find do |t|
101
+ lang_code = t["languages_code"].to_s.split("-").first.downcase
102
+ lang_code == locale.to_s
103
+ end
104
+ end
105
+
106
+ # Apply translations to mapped data
107
+ # @param collection_config [CollectionConfig] The collection configuration
108
+ # @param translation [Hash] The translation data
109
+ # @param mapped_data [Hash] The mapped data to update
110
+ # @return [void]
111
+ def apply_translations(collection_config, translation, mapped_data)
112
+ collection_config.translatable_fields.each do |field|
113
+ # Get the Directus field name for this Bridgetown field
114
+ field_config = collection_config.fields[field]
115
+
116
+ directus_field = if field_config.nil?
117
+ field.to_s
118
+ elsif field_config.is_a?(Hash)
119
+ field_config[:directus_field]
120
+ else
121
+ field_config.to_s
122
+ end
123
+
124
+ directus_field = field.to_s if directus_field.to_s.empty?
125
+
126
+ # Check if the translation has this field
127
+ next unless translation[directus_field]
128
+
129
+ value = translation[directus_field]
130
+
131
+ # Apply converter if provided
132
+ if field_config.is_a?(Hash) && field_config[:converter].respond_to?(:call)
133
+ value = field_config[:converter].call(value)
134
+ end
135
+
136
+ mapped_data[field] = value
137
+ end
138
+ end
139
+
140
+ # Extract a value from nested data using dot notation
141
+ # @param data [Hash] The data to extract from
142
+ # @param field [String] The field path (e.g., "user.profile.name")
143
+ # @return [Object] The extracted value
144
+ def extract_value(data, field)
145
+ return nil unless data
146
+
147
+ keys = field.to_s.split(".")
148
+ value = data
149
+
150
+ keys.each do |key|
151
+ return nil unless value.is_a?(Hash) && value.key?(key)
152
+
153
+ value = value[key]
154
+ end
155
+
156
+ value
157
+ end
158
+ end
159
+ end
160
+ end
@@ -3,7 +3,12 @@
3
3
  module BridgetownDirectus
4
4
  module Utils
5
5
  def self.log_directus(message)
6
- Bridgetown.logger.info("Directus") { message }
6
+ if defined?(Bridgetown) && Bridgetown.respond_to?(:logger)
7
+ Bridgetown.logger.info("Directus") { message }
8
+ elsif ENV["BRIDGETOWN_DIRECTUS_DEBUG"]
9
+ # Fallback for testing or when Bridgetown is not available
10
+ puts "[Directus] #{message}"
11
+ end
7
12
  end
8
13
  end
9
14
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BridgetownDirectus
4
- VERSION = "0.1.3"
4
+ VERSION = "0.3.0"
5
5
  end