joys 0.1.2 → 0.1.4

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.
data/lib/joys/data.rb ADDED
@@ -0,0 +1,477 @@
1
+ # frozen_string_literal: true
2
+ # lib/joys/data.rb - Ruby hash-based data querying
3
+
4
+ require 'json'
5
+ require 'date'
6
+
7
+ module Joys
8
+ module Data
9
+ # Custom error for data file issues
10
+ class DataFileError < StandardError; end
11
+ class ScopeError < StandardError; end
12
+ class ItemMethodError < StandardError; end
13
+
14
+ class << self
15
+ def configure(data_path: 'data')
16
+ @data_path = data_path
17
+ end
18
+
19
+ def define(model_name, &block)
20
+ context = DefinitionContext.new(model_name, @data_path || 'data')
21
+ context.instance_eval(&block)
22
+
23
+ # Store the loaded data and methods
24
+ @collections ||= {}
25
+ @collections[model_name.to_sym] = {
26
+ data: context.loaded_data,
27
+ scopes: context.scopes,
28
+ item_methods: context.item_methods
29
+ }
30
+ end
31
+
32
+ def query(model_name)
33
+ model_name = model_name.to_sym
34
+ collection = @collections[model_name]
35
+
36
+ unless collection
37
+ raise "Collection #{model_name} not defined. Use Joys::Data.define :#{model_name} to define it."
38
+ end
39
+
40
+ Query.new(
41
+ model_name,
42
+ collection[:data],
43
+ collection[:scopes],
44
+ collection[:item_methods]
45
+ )
46
+ end
47
+
48
+ # Development helper methods
49
+ def reload
50
+ @collections = {}
51
+ end
52
+
53
+ def defined_models
54
+ (@collections || {}).keys
55
+ end
56
+ end
57
+
58
+ class DefinitionContext
59
+ attr_reader :loaded_data, :scopes, :item_methods
60
+
61
+ def initialize(model_name, data_path)
62
+ @model_name = model_name
63
+ @data_path = data_path
64
+ @loaded_data = []
65
+ @scopes = {}
66
+ @item_methods = {}
67
+ end
68
+
69
+ def load(pattern)
70
+ file_pattern = File.join(@data_path, pattern)
71
+ files = Dir[file_pattern].sort
72
+
73
+ if files.empty?
74
+ raise "No files found matching pattern: #{file_pattern}"
75
+ end
76
+
77
+ @loaded_data = files.map do |file|
78
+ begin
79
+ # Each file should return a hash - evaluate in clean context
80
+ hash_data = eval(File.read(file), TOPLEVEL_BINDING, file)
81
+ unless hash_data.is_a?(::Hash)
82
+ raise DataFileError, "#{file} should return a Hash, but returned #{hash_data.class}.\n\nMake sure your data file ends with a hash like:\n{\n title: \"My Post\",\n content: \"...\"\n}"
83
+ end
84
+ hash_data.freeze # Make immutable
85
+ rescue DataFileError
86
+ raise # Re-raise our custom errors as-is
87
+ rescue SyntaxError => e
88
+ raise DataFileError, "#{file} has a syntax error:\n#{e.message}\n\nCheck for missing commas, quotes, or brackets."
89
+ rescue => e
90
+ raise DataFileError, "#{file} couldn't be loaded:\n#{e.message}\n\nMake sure the file contains valid Ruby code that returns a Hash."
91
+ end
92
+ end
93
+ end
94
+
95
+ def from_array(array)
96
+ unless array.is_a?(Array)
97
+ raise DataFileError, "from_array() expects an Array of hashes, but got #{array.class}.\n\nExample:\nfrom_array([\n { title: \"First Post\", content: \"...\" },\n { title: \"Second Post\", content: \"...\" }\n])"
98
+ end
99
+
100
+ @loaded_data = array.map.with_index do |item, index|
101
+ unless item.is_a?(::Hash)
102
+ raise DataFileError, "from_array() array item #{index + 1} should be a Hash, but got #{item.class}.\n\nEach item in the array should look like:\n{ title: \"Post Title\", content: \"Post content\" }"
103
+ end
104
+ item.freeze # Make immutable
105
+ end
106
+ end
107
+
108
+ def scope(methods_hash)
109
+ methods_hash.each do |name, lambda_proc|
110
+ unless lambda_proc.respond_to?(:call)
111
+ raise "Scope #{name} must be callable (lambda or proc)"
112
+ end
113
+ @scopes[name.to_sym] = lambda_proc
114
+ end
115
+ end
116
+
117
+ def item(methods_hash)
118
+ methods_hash.each do |name, lambda_proc|
119
+ unless lambda_proc.respond_to?(:call)
120
+ raise "Item method #{name} must be callable (lambda or proc)"
121
+ end
122
+ @item_methods[name.to_sym] = lambda_proc
123
+ end
124
+ end
125
+ end
126
+
127
+ class Query
128
+ include Enumerable
129
+
130
+ def initialize(model_name, data, scopes, item_methods, conditions: [], order_field: nil, order_desc: false, limit_count: nil, offset_count: nil)
131
+ @model_name = model_name
132
+ @data = data
133
+ @scopes = scopes
134
+ @item_methods = item_methods
135
+ @conditions = conditions
136
+ @order_field = order_field
137
+ @order_desc = order_desc
138
+ @limit_count = limit_count
139
+ @offset_count = offset_count
140
+
141
+ # Dynamically define scope methods with better error context
142
+ @scopes.each do |scope_name, scope_lambda|
143
+ define_singleton_method(scope_name) do |*args|
144
+ begin
145
+ instance_exec(*args, &scope_lambda)
146
+ rescue => e
147
+ raise "Error in scope :#{scope_name} for model :#{@model_name}: #{e.message}\n #{e.backtrace&.first}"
148
+ end
149
+ end
150
+ end
151
+ end
152
+
153
+ # Core query methods
154
+ def where(conditions)
155
+ Query.new(@model_name, @data, @scopes, @item_methods,
156
+ conditions: @conditions + [conditions],
157
+ order_field: @order_field,
158
+ order_desc: @order_desc,
159
+ limit_count: @limit_count,
160
+ offset_count: @offset_count)
161
+ end
162
+
163
+ def order(field, desc: false)
164
+ Query.new(@model_name, @data, @scopes, @item_methods,
165
+ conditions: @conditions,
166
+ order_field: field,
167
+ order_desc: desc,
168
+ limit_count: @limit_count,
169
+ offset_count: @offset_count)
170
+ end
171
+
172
+ def limit(count)
173
+ Query.new(@model_name, @data, @scopes, @item_methods,
174
+ conditions: @conditions,
175
+ order_field: @order_field,
176
+ order_desc: @order_desc,
177
+ limit_count: count,
178
+ offset_count: @offset_count)
179
+ end
180
+
181
+ def offset(count)
182
+ Query.new(@model_name, @data, @scopes, @item_methods,
183
+ conditions: @conditions,
184
+ order_field: @order_field,
185
+ order_desc: @order_desc,
186
+ limit_count: @limit_count,
187
+ offset_count: count)
188
+ end
189
+
190
+ # Terminal methods
191
+ def all
192
+ apply_filters.map { |hash| wrap_instance(hash) }
193
+ end
194
+
195
+ def first
196
+ result = apply_filters.first
197
+ result ? wrap_instance(result) : nil
198
+ end
199
+
200
+ def last
201
+ result = apply_filters.last
202
+ result ? wrap_instance(result) : nil
203
+ end
204
+
205
+ def count
206
+ apply_filters.length
207
+ end
208
+
209
+ def find_by(conditions)
210
+ where(conditions).first
211
+ end
212
+
213
+ # Enumerable support
214
+ def each(&block)
215
+ all.each(&block)
216
+ end
217
+
218
+ # Pagination support
219
+ def paginate(per_page:)
220
+ raise ArgumentError, "per_page must be positive" if per_page <= 0
221
+ total_items = count
222
+ return [Page.new([], 1, 1, total_items)] if total_items == 0
223
+ total_pages = (total_items.to_f / per_page).ceil
224
+ (1..total_pages).map do |page_num|
225
+ offset_val = (page_num - 1) * per_page
226
+ # Respect existing limits - don't override them
227
+ items_per_page = if @limit_count && (@limit_count < per_page)
228
+ # If existing limit is smaller than per_page, use the existing limit
229
+ remaining_items = [@limit_count - offset_val, 0].max
230
+ [remaining_items, per_page].min
231
+ else
232
+ per_page
233
+ end
234
+ items = self.offset(offset_val).limit(items_per_page).all
235
+ Page.new(items, page_num, total_pages, total_items)
236
+ end
237
+ end
238
+
239
+ private
240
+
241
+ def apply_filters
242
+ result = @data.dup
243
+
244
+ # Apply conditions
245
+ @conditions.each do |condition|
246
+ result = result.select do |item|
247
+ condition.all? do |field, value|
248
+ evaluate_condition(item, field.to_sym, value) # Normalize field to symbol
249
+ end
250
+ end
251
+ end
252
+
253
+ # Apply ordering
254
+ if @order_field
255
+ # Separate nil values to always sort them to the end
256
+ nil_items = result.select { |item| item[@order_field.to_sym].nil? }
257
+ non_nil_items = result.reject { |item| item[@order_field.to_sym].nil? }
258
+
259
+ # Sort non-nil items with proper type handling
260
+ sorted_non_nil = non_nil_items.sort_by do |item|
261
+ value = item[@order_field.to_sym]
262
+ # Handle different types properly for sorting
263
+ case value
264
+ when Numeric
265
+ [0, value] # Numeric values first, keep as numeric
266
+ when String
267
+ [1, value] # Strings second, keep as string
268
+ when Date, Time
269
+ [2, value] # Dates third, keep as date
270
+ else
271
+ [3, value.to_s] # Everything else as string
272
+ end
273
+ end
274
+
275
+ # Combine: non-nil items + nil items (nil always at end)
276
+ result = sorted_non_nil + nil_items
277
+
278
+ # For descending, reverse only the non-nil portion
279
+ if @order_desc
280
+ result = sorted_non_nil.reverse + nil_items
281
+ end
282
+ end
283
+
284
+ # Apply offset
285
+ if @offset_count && @offset_count > 0
286
+ result = result[@offset_count..-1] || []
287
+ end
288
+
289
+ # Apply limit
290
+ if @limit_count
291
+ result = result[0, @limit_count] || []
292
+ end
293
+
294
+ result
295
+ end
296
+
297
+ def evaluate_condition(item, field, value)
298
+ field_value = item[field]
299
+
300
+ case value
301
+ when ::Hash
302
+ if value.key?(:contains)
303
+ field_value.to_s.include?(value[:contains].to_s)
304
+ elsif value.key?(:starts_with)
305
+ field_value.to_s.start_with?(value[:starts_with].to_s)
306
+ elsif value.key?(:ends_with)
307
+ field_value.to_s.end_with?(value[:ends_with].to_s)
308
+ elsif value.key?(:greater_than)
309
+ field_value && field_value > value[:greater_than]
310
+ elsif value.key?(:less_than)
311
+ field_value && field_value < value[:less_than]
312
+ elsif value.key?(:greater_than_or_equal)
313
+ field_value && field_value >= value[:greater_than_or_equal]
314
+ elsif value.key?(:less_than_or_equal)
315
+ field_value && field_value <= value[:less_than_or_equal]
316
+ elsif value.key?(:from) && value.key?(:to)
317
+ field_value && field_value >= value[:from] && field_value <= value[:to]
318
+ elsif value.key?(:from)
319
+ field_value && field_value >= value[:from]
320
+ elsif value.key?(:to)
321
+ field_value && field_value <= value[:to]
322
+ elsif value.key?(:in)
323
+ Array(value[:in]).include?(field_value)
324
+ elsif value.key?(:not_in)
325
+ !Array(value[:not_in]).include?(field_value)
326
+ elsif value.key?(:not)
327
+ field_value != value[:not]
328
+ elsif value.key?(:exists)
329
+ value[:exists] ? !field_value.nil? : field_value.nil?
330
+ elsif value.key?(:empty)
331
+ if value[:empty]
332
+ field_value.nil? || field_value == '' || (field_value.respond_to?(:empty?) && field_value.empty?)
333
+ else
334
+ !field_value.nil? && field_value != '' && !(field_value.respond_to?(:empty?) && field_value.empty?)
335
+ end
336
+ else
337
+ field_value == value
338
+ end
339
+ when Array
340
+ if field_value.is_a?(Array)
341
+ # For array fields, check if they contain the same elements
342
+ field_value == value
343
+ else
344
+ # For non-array fields, check if field_value is in the array
345
+ value.include?(field_value)
346
+ end
347
+ when Range
348
+ value.include?(field_value)
349
+ else
350
+ field_value == value
351
+ end
352
+ end
353
+
354
+ def wrap_instance(hash)
355
+ InstanceWrapper.new(hash, @item_methods)
356
+ end
357
+ end
358
+
359
+ class Page
360
+ attr_reader :items, :current_page, :total_pages, :total_items,
361
+ :prev_page, :next_page, :is_first_page, :is_last_page
362
+
363
+ def initialize(items, current_page, total_pages, total_items)
364
+ @items = items
365
+ @current_page = current_page
366
+ @total_pages = total_pages
367
+ @total_items = total_items
368
+ @prev_page = current_page > 1 ? current_page - 1 : nil
369
+ @next_page = current_page < total_pages ? current_page + 1 : nil
370
+ @is_first_page = current_page == 1
371
+ @is_last_page = current_page == total_pages
372
+ end
373
+
374
+ # Aliases for consistency with your SSG (e.g., 'posts' instead of 'items')
375
+ alias_method :posts, :items
376
+
377
+ def to_h
378
+ {
379
+ items: @items,
380
+ current_page: @current_page,
381
+ total_pages: @total_pages,
382
+ total_items: @total_items,
383
+ prev_page: @prev_page,
384
+ next_page: @next_page,
385
+ is_first_page: @is_first_page,
386
+ is_last_page: @is_last_page
387
+ }
388
+ end
389
+ end
390
+
391
+ class InstanceWrapper
392
+ def initialize(hash, item_methods)
393
+ @hash = hash
394
+ @item_methods = item_methods
395
+ @memoized = {}
396
+
397
+ # Dynamically define item methods with better error context
398
+ @item_methods.each do |method_name, method_lambda|
399
+ define_singleton_method(method_name) do |*args|
400
+ # Memoize results to avoid recomputation
401
+ cache_key = [method_name, args]
402
+
403
+ unless @memoized.key?(cache_key)
404
+ begin
405
+ @memoized[cache_key] = instance_exec(*args, &method_lambda)
406
+ rescue => e
407
+ raise "Error in item method :#{method_name}: #{e.message}\n #{e.backtrace&.first}"
408
+ end
409
+ end
410
+
411
+ @memoized[cache_key]
412
+ end
413
+ end
414
+ end
415
+
416
+ # Hash-style access with symbol normalization
417
+ def [](key)
418
+ # Try symbol first, then string, then original key
419
+ @hash[key.to_sym] || @hash[key]
420
+ end
421
+
422
+ def []=(key, value)
423
+ raise "Joys::Data collections are immutable. Cannot assign #{key} = #{value}"
424
+ end
425
+
426
+ def has_key?(key)
427
+ @hash.has_key?(key.to_sym) || @hash.has_key?(key.to_s)
428
+ end
429
+
430
+ def include?(key)
431
+ @hash.include?(key.to_sym) || @hash.include?(key.to_s)
432
+ end
433
+
434
+ def key?(key)
435
+ @hash.key?(key.to_sym) || @hash.key?(key.to_s)
436
+ end
437
+
438
+ def keys
439
+ @hash.keys
440
+ end
441
+
442
+ def values
443
+ @hash.values
444
+ end
445
+
446
+ def to_h
447
+ @hash
448
+ end
449
+
450
+ def to_hash
451
+ @hash
452
+ end
453
+
454
+ # Make it behave like a hash for other operations
455
+ def method_missing(method, *args, &block)
456
+ if @hash.respond_to?(method)
457
+ @hash.send(method, *args, &block)
458
+ else
459
+ super
460
+ end
461
+ end
462
+
463
+ def respond_to_missing?(method, include_private = false)
464
+ @hash.respond_to?(method, include_private) || super
465
+ end
466
+
467
+ def inspect
468
+ "#<Joys::Data::Instance #{@hash.inspect}>"
469
+ end
470
+ end
471
+ end
472
+ end
473
+
474
+ # Global helper method for cleaner syntax
475
+ def data(model_name)
476
+ Joys::Data.query(model_name)
477
+ end
data/lib/joys/helpers.rb CHANGED
@@ -5,6 +5,7 @@ module Joys
5
5
  module Helpers
6
6
  def txt(content); @bf << CGI.escapeHTML(content.to_s); nil; end
7
7
  def raw(content); @bf << content.to_s; nil; end
8
+ def _(content); @bf << content.to_s; nil; end
8
9
  def push(name, &block)
9
10
  old_buffer = @bf;@slots ||= {}
10
11
  @bf = String.new;instance_eval(&block)
@@ -14,15 +15,26 @@ module Joys
14
15
  def pull(name = :main)
15
16
  @slots ||= {};@bf << @slots[name].to_s if @slots[name];nil
16
17
  end
17
- def pull_styles;@bf << "<!--STYLES-->";nil;end
18
+ def pull_styles
19
+ @bf << "<!--STYLES-->"
20
+ nil
21
+ end
18
22
  def pull_external_styles;@bf << "<!--EXTERNAL_STYLES-->";nil;end
19
23
 
20
- def comp(name, *args, **kwargs, &block)
21
- comp_name = "comp_#{name}"
22
- @used_components ||= Set.new
23
- @used_components.add(comp_name)
24
- raw Joys.send(comp_name, *args, **kwargs, &block)
25
- end
24
+ def comp(name, *args, **kwargs, &block)
25
+ comp_name = "comp_#{name}"
26
+ @used_components ||= Set.new
27
+ @used_components.add(comp_name) # 1. Register the component being called.
28
+
29
+ # 2. Call the component's render method. This now returns our hash.
30
+ result = Joys.send(comp_name, *args, **kwargs, &block)
31
+
32
+ # 3. CRITICAL FIX: Merge the nested dependencies from the result into the current context.
33
+ @used_components.merge(result[:components]) if result[:components]
34
+
35
+ # 4. Render only the HTML part into the buffer.
36
+ raw result[:html]
37
+ end
26
38
  def layout(name, &block)
27
39
  layout_lambda = Joys.layouts[name]
28
40
  raise ArgumentError, "Layout `#{name}` not registered" unless layout_lambda
@@ -32,25 +44,35 @@ end
32
44
  context_name = Joys.current_component
33
45
  context_name ||= Joys.current_page
34
46
  context_name ||= @current_page
47
+
35
48
  return nil unless context_name
36
- return nil if Joys.compiled_styles[context_name]
49
+ return nil if Joys.compiled_styles[context_name] && !Joys::Config.dev? # Check dev mode
50
+
37
51
  @style_base_css = []
38
52
  @style_media_queries = {}
39
53
  @style_scoped = scoped
40
54
  instance_eval(&block)
55
+
41
56
  Joys::Styles.compile_component_styles(
42
57
  context_name,
43
58
  @style_base_css,
44
59
  @style_media_queries,
45
60
  @style_scoped
46
61
  )
47
- if context_name&.start_with?('page_')
62
+
63
+ if context_name&.start_with?('page_') || context_name&.start_with?('comp_')
48
64
  @used_components ||= Set.new
49
65
  @used_components.add(context_name)
50
66
  end
51
67
  @style_base_css = @style_media_queries = @style_scoped = nil;nil
52
68
  end
53
69
  def doctype;raw "<!doctype html>";end
70
+ def load_css(*args)
71
+ args.map do |name|
72
+ @style_base_css&.push(Joys.css_registry[name]||raise("CSS part not found: #{name}"))
73
+ end
74
+ nil
75
+ end
54
76
  def css(content);@style_base_css&.push(content);nil;end
55
77
  def media_max(breakpoint, content)
56
78
  return nil unless @style_media_queries
@@ -106,6 +128,11 @@ end
106
128
  @style_media_queries[key] ||= []
107
129
  @style_media_queries[key] << content;nil
108
130
  end
131
+ def cycle(i, *choices)
132
+ choices[i % choices.length]
133
+ end
134
+
135
+
109
136
  end
110
137
  end
111
138
  end