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/ssg.rb ADDED
@@ -0,0 +1,597 @@
1
+ # frozen_string_literal: true
2
+ # lib/joys/ssg.rb - Static Site Generation for hash-based data
3
+
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'set'
7
+ require 'digest'
8
+
9
+ module Joys
10
+ module SSG
11
+ module_function
12
+
13
+ @output_dir = 'dist'
14
+ @dependencies = { templates: {}, data: {} }
15
+ @asset_manifest = {}
16
+ @error_pages = {}
17
+
18
+ class << self
19
+ attr_accessor :output_dir
20
+ attr_reader :dependencies, :asset_manifest, :error_pages
21
+ end
22
+
23
+ def build(content_dir, output_dir = @output_dir, force: false)
24
+ #puts "Building static site: #{content_dir} → #{output_dir}"
25
+ @dependencies = { templates: {}, data: {} }
26
+ @asset_manifest = {}
27
+ setup_output_dir(output_dir, force)
28
+ load_data_definitions(content_dir)
29
+ load_components_and_layouts(content_dir)
30
+ pages = discover_pages(content_dir)
31
+ built_files = build_pages(pages, output_dir, content_dir)
32
+ build_error_pages(output_dir, content_dir)
33
+ process_assets(content_dir, output_dir)
34
+ save_dependencies(output_dir)
35
+ save_asset_manifest(output_dir)
36
+ #puts "Built #{built_files.size} pages"
37
+ built_files
38
+ end
39
+
40
+ def build_incremental(content_dir, changed_files, output_dir = @output_dir)
41
+ #puts "Incremental build for: #{changed_files.map { |f| File.basename(f) }.join(', ')}"
42
+ load_dependencies(output_dir)
43
+ load_asset_manifest(output_dir)
44
+ affected_pages = find_affected_pages(changed_files)
45
+ if affected_pages.empty?
46
+ #puts "No pages affected by changes"
47
+ return []
48
+ end
49
+ if changed_files.any? { |f| f.include?('/data/') }
50
+ load_data_definitions(content_dir)
51
+ end
52
+ if changed_files.any? { |f| f.include?('/_components/') || f.include?('/_layouts/') }
53
+ load_components_and_layouts(content_dir)
54
+ end
55
+ build_pages(affected_pages, output_dir, content_dir)
56
+ end
57
+
58
+ def setup_output_dir(output_dir, force)
59
+ FileUtils.rm_rf(output_dir) if force && Dir.exist?(output_dir)
60
+ FileUtils.mkdir_p(output_dir)
61
+ end
62
+
63
+ def load_data_definitions(content_dir)
64
+ data_dir = File.join(content_dir, '../data')
65
+ return unless Dir.exist?(data_dir)
66
+ Joys::Data.configure(data_path: data_dir) if defined?(Joys::Data)
67
+ Dir.glob("#{data_dir}/**/*_data.rb").each do |file|
68
+ #puts " Loading data: #{File.basename(file)}"
69
+ load file
70
+ end
71
+ end
72
+
73
+ def load_components_and_layouts(content_dir)
74
+ Dir.glob("#{content_dir}/**/_components/*.rb").each do |file|
75
+ #puts " Loading component: #{File.basename(file, '.rb')}"
76
+ load file
77
+ end
78
+ Dir.glob("#{content_dir}/**/_layouts/*.rb").each do |file|
79
+ #puts " Loading layout: #{File.basename(file, '.rb')}"
80
+ load file
81
+ end
82
+ end
83
+
84
+ def discover_pages(content_dir)
85
+ pages = []
86
+ error_pages = []
87
+
88
+ Dir.glob("#{content_dir}/**/*.rb").each do |file|
89
+ next if file.include?('/_components/') ||
90
+ file.include?('/_layouts/')
91
+
92
+ page_info = analyze_page_file(file, content_dir)
93
+ next unless page_info
94
+
95
+ # Check if this is an error page (404.rb, 500.rb, etc.)
96
+ basename = File.basename(file, '.rb')
97
+ if basename.match?(/^\d{3}$/) # HTTP status codes
98
+ page_info[:error_code] = basename.to_i
99
+ error_pages << page_info
100
+ else
101
+ pages << page_info
102
+ end
103
+ end
104
+
105
+ # Store error pages for later use
106
+ @error_pages = error_pages.group_by { |p| [p[:domain], p[:error_code]] }
107
+
108
+ pages.compact
109
+ end
110
+
111
+ def analyze_page_file(file_path, content_dir)
112
+ domain_match = file_path.match(%r{#{Regexp.escape(content_dir)}/([^/]+)/})
113
+ return nil unless domain_match
114
+ domain = domain_match[1]
115
+ relative_path = file_path.sub("#{content_dir}/#{domain}/", '')
116
+ url_path = file_path_to_url(relative_path)
117
+ {
118
+ file_path: file_path,
119
+ domain: domain,
120
+ url_path: url_path,
121
+ output_path: url_to_output_path(domain, url_path)
122
+ }
123
+ end
124
+
125
+ def file_path_to_url(file_path)
126
+ path = file_path.sub(/\.rb$/, '')
127
+ path = path == 'index' ? '/' : "/#{path}/"
128
+ path.gsub(/_([^\/]+)/, ':\1') # Convert _id to :id (though not used in SSG)
129
+ end
130
+
131
+ def url_to_output_path(domain, url_path)
132
+ base = domain == 'default' ? '' : "#{domain}/"
133
+ if url_path == '/'
134
+ "#{base}index.html"
135
+ else
136
+ clean_path = url_path.gsub(/[^a-zA-Z0-9\/\-_]/, '') # Remove special chars
137
+ "#{base}#{clean_path.sub(/^\//, '').sub(/\/$/, '')}/index.html"
138
+ end
139
+ end
140
+
141
+ def build_pages(pages, output_dir, content_dir)
142
+ built_files = []
143
+ pages.each do |page|
144
+ page_results = build_single_page(page, content_dir)
145
+ next if page_results.empty?
146
+
147
+ page_results.each do |result|
148
+ output_file = File.join(output_dir, result[:output_path])
149
+ FileUtils.mkdir_p(File.dirname(output_file))
150
+ File.write(output_file, result[:html])
151
+
152
+ built_files << result[:output_path]
153
+ #puts " Built: #{result[:output_path]}"
154
+ end
155
+ end
156
+ built_files
157
+ end
158
+
159
+ def build_single_page(page, content_dir)
160
+ context = PageContext.new(page[:file_path], content_dir)
161
+ begin
162
+ source_code = File.read(page[:file_path])
163
+
164
+ if source_code.include?('paginate')
165
+ return build_paginated_pages(page, source_code, context, content_dir)
166
+ else
167
+ html = context.instance_eval(source_code, page[:file_path])
168
+
169
+ @dependencies[:templates][page[:file_path]] = context.used_dependencies
170
+
171
+ return [{ html: html, output_path: page[:output_path] }]
172
+ end
173
+ rescue => e
174
+ #puts " Error building #{page[:output_path]}: #{e.message}"
175
+ return []
176
+ end
177
+ end
178
+
179
+ def build_paginated_pages(page, source_code, context, content_dir)
180
+ pages_generated = []
181
+ context.setup_pagination_capture(page)
182
+ context.instance_eval(source_code, page[:file_path])
183
+ context.captured_paginations.each do |pagination|
184
+ pagination[:pages].each_with_index do |page_obj, index|
185
+ page_context = PageContext.new(page[:file_path], content_dir)
186
+ page_context.current_pagination_page = page_obj
187
+
188
+ html = page_context.instance_eval(pagination[:block_source], page[:file_path])
189
+
190
+ output_path = generate_pagination_path(page[:output_path], index + 1)
191
+ pages_generated << { html: html, output_path: output_path }
192
+ end
193
+ end
194
+ @dependencies[:templates][page[:file_path]] = context.used_dependencies
195
+ pages_generated
196
+ end
197
+
198
+ def generate_pagination_path(base_path, page_number)
199
+ if page_number == 1
200
+ base_path
201
+ else
202
+ base_path.sub(/\/index\.html$/, "/page-#{page_number}/index.html")
203
+ end
204
+ end
205
+
206
+ def process_assets(content_dir, output_dir)
207
+ # Copy global assets from public/ to root (no hashing)
208
+ copy_global_assets(output_dir)
209
+
210
+ # Process domain-specific assets with hashing
211
+ process_domain_assets(content_dir, output_dir)
212
+ end
213
+
214
+ def copy_global_assets(output_dir)
215
+ ['public', 'static'].each do |dir|
216
+ next unless Dir.exist?(dir)
217
+ Dir.glob("#{dir}/**/*").each do |file|
218
+ next if File.directory?(file)
219
+
220
+ relative = file.sub("#{dir}/", '')
221
+ output_file = File.join(output_dir, relative)
222
+
223
+ FileUtils.mkdir_p(File.dirname(output_file))
224
+ FileUtils.cp(file, output_file)
225
+ #puts " Copied global asset: #{relative}"
226
+ end
227
+ end
228
+ end
229
+
230
+ def process_domain_assets(content_dir, output_dir)
231
+ # Discover all domains
232
+ domains = Dir.glob("#{content_dir}/*/").map { |d| File.basename(d) }
233
+
234
+ domains.each do |domain|
235
+ asset_dir = File.join(content_dir, domain, 'assets')
236
+ next unless Dir.exist?(asset_dir)
237
+
238
+ # Determine output path
239
+ assets_output_dir = if domain == 'default'
240
+ File.join(output_dir, 'assets')
241
+ else
242
+ File.join(output_dir, domain, 'assets')
243
+ end
244
+
245
+ FileUtils.mkdir_p(assets_output_dir)
246
+
247
+ # Process each asset file
248
+ Dir.glob("#{asset_dir}/**/*").each do |file|
249
+ next if File.directory?(file)
250
+
251
+ process_domain_asset(file, asset_dir, assets_output_dir, domain)
252
+ end
253
+ end
254
+ end
255
+
256
+ def process_domain_asset(file_path, asset_dir, output_dir, domain)
257
+ # Get relative path within assets directory
258
+ relative_path = file_path.sub("#{asset_dir}/", '')
259
+
260
+ # Read file and generate hash
261
+ content = File.read(file_path)
262
+ hash = Digest::MD5.hexdigest(content)[0, 8]
263
+
264
+ # Generate hashed filename
265
+ ext = File.extname(relative_path)
266
+ base = File.basename(relative_path, ext)
267
+ dir = File.dirname(relative_path)
268
+
269
+ hashed_filename = "#{base}-#{hash}#{ext}"
270
+ hashed_relative = dir == '.' ? hashed_filename : "#{dir}/#{hashed_filename}"
271
+
272
+ # Output file path
273
+ output_file = File.join(output_dir, hashed_relative)
274
+ FileUtils.mkdir_p(File.dirname(output_file))
275
+ File.write(output_file, content)
276
+
277
+ # Store in manifest
278
+ manifest_key = "#{domain}/#{relative_path}"
279
+ manifest_value = if domain == 'default'
280
+ "/assets/#{hashed_relative}"
281
+ else
282
+ "/#{domain}/assets/#{hashed_relative}"
283
+ end
284
+
285
+ @asset_manifest[manifest_key] = manifest_value
286
+ #puts " Processed asset: #{manifest_key} → #{manifest_value}"
287
+ end
288
+
289
+ def save_dependencies(output_dir)
290
+ deps_file = File.join(output_dir, '.ssg_dependencies.json')
291
+ File.write(deps_file, JSON.pretty_generate(@dependencies))
292
+ end
293
+
294
+ def save_asset_manifest(output_dir)
295
+ manifest_file = File.join(output_dir, '.asset_manifest.json')
296
+ File.write(manifest_file, JSON.pretty_generate(@asset_manifest))
297
+ end
298
+
299
+ def load_dependencies(output_dir)
300
+ deps_file = File.join(output_dir, '.ssg_dependencies.json')
301
+ return unless File.exist?(deps_file)
302
+ @dependencies = JSON.parse(File.read(deps_file))
303
+ end
304
+
305
+ def load_asset_manifest(output_dir)
306
+ manifest_file = File.join(output_dir, '.asset_manifest.json')
307
+ return unless File.exist?(manifest_file)
308
+ @asset_manifest = JSON.parse(File.read(manifest_file))
309
+ end
310
+
311
+ def find_affected_pages(changed_files)
312
+ affected = Set.new
313
+ changed_files.each do |file|
314
+ if file.include?('/_components/') || file.include?('/_layouts/')
315
+ template_name = File.basename(file, '.rb')
316
+
317
+ @dependencies['templates']&.each do |page_file, deps|
318
+ if deps['components']&.include?(template_name) ||
319
+ deps['layouts']&.include?(template_name)
320
+ affected.add(page_file)
321
+ end
322
+ end
323
+
324
+ elsif file.include?('/data/')
325
+ data_name = File.basename(file, '_data.rb')
326
+
327
+ @dependencies['data']&.dig(data_name)&.each do |page_file|
328
+ affected.add(page_file)
329
+ end
330
+
331
+ elsif file.end_with?('.rb')
332
+ affected.add(file)
333
+ end
334
+ end
335
+ affected.map { |file| { file_path: file } }.compact
336
+ end
337
+
338
+ def build_error_pages(output_dir, content_dir)
339
+ return if @error_pages.empty?
340
+
341
+ @error_pages.each do |(domain, error_code), error_pages|
342
+ error_page = error_pages.first # Take the first if multiple
343
+
344
+ # Build the error page
345
+ page_results = build_single_page(error_page, content_dir)
346
+ next if page_results.empty?
347
+
348
+ page_results.each do |result|
349
+ # Save error pages with their status code names
350
+ if domain == 'default'
351
+ output_file = File.join(output_dir, "#{error_code}.html")
352
+ else
353
+ output_file = File.join(output_dir, domain, "#{error_code}.html")
354
+ end
355
+
356
+ FileUtils.mkdir_p(File.dirname(output_file))
357
+ File.write(output_file, result[:html])
358
+
359
+ #puts " Built error page: #{error_code}.html (#{domain})"
360
+ end
361
+ end
362
+ end
363
+
364
+ def render_error_page(domain, error_code, context = {})
365
+ # Find the appropriate error page
366
+ error_page_info = @error_pages[[domain, error_code]]&.first
367
+ error_page_info ||= @error_pages[['default', error_code]]&.first
368
+
369
+ return nil unless error_page_info
370
+
371
+ # Create a context with the error information
372
+ page_context = PageContext.new(error_page_info[:file_path], File.dirname(error_page_info[:file_path]))
373
+
374
+ # Assign context variables as locals (avoiding instance variable caveat)
375
+ context.each do |key, value|
376
+ page_context.instance_variable_set("@#{key}", value)
377
+ end
378
+
379
+ begin
380
+ source_code = File.read(error_page_info[:file_path])
381
+ page_context.instance_eval(source_code, error_page_info[:file_path])
382
+ rescue => e
383
+ #puts " Error rendering error page #{error_code}: #{e.message}"
384
+ nil
385
+ end
386
+ end
387
+
388
+ class PageContext
389
+ include Joys::Render::Helpers if defined?(Joys::Render::Helpers)
390
+ include Joys::Tags if defined?(Joys::Tags)
391
+
392
+ attr_reader :used_dependencies, :captured_paginations, :content_dir, :page_file_path
393
+ attr_accessor :current_pagination_page
394
+
395
+ def initialize(page_file_path, content_dir)
396
+ @page_file_path = page_file_path
397
+ @content_dir = content_dir
398
+ @used_dependencies = { 'components' => [], 'layouts' => [], 'data' => [] }
399
+ @captured_paginations = []
400
+ @current_pagination_page = nil
401
+ @bf = String.new # Buffer for HTML output
402
+ @slots = {} # Slots for layout system
403
+
404
+ # Load asset manifest for asset helpers
405
+ manifest_file = File.join(SSG.output_dir, '.asset_manifest.json')
406
+ @asset_manifest = if File.exist?(manifest_file)
407
+ JSON.parse(File.read(manifest_file))
408
+ else
409
+ {}
410
+ end
411
+ end
412
+
413
+ def setup_pagination_capture(page)
414
+ @capturing_pagination = true
415
+ @base_page = page
416
+ end
417
+
418
+ def params
419
+ {}
420
+ end
421
+
422
+ def session
423
+ {}
424
+ end
425
+
426
+ def html(&block)
427
+ if defined?(Joys) && Joys.respond_to?(:html)
428
+ Joys.html(&block)
429
+ else
430
+ yield if block_given?
431
+ end
432
+ end
433
+
434
+ def layout(name, &block)
435
+ @used_dependencies['layouts'] << name.to_s
436
+ if defined?(Joys) && Joys.layouts.key?(name)
437
+ layout_lambda = Joys.layouts[name]
438
+ # Create a temporary buffer for the layout content
439
+ old_bf = @bf
440
+ @bf = String.new
441
+ result = layout_lambda.call(self, &block)
442
+ @bf = old_bf
443
+ @bf << result if result
444
+ else
445
+ # Fallback for when Joys layout isn't available
446
+ yield if block_given?
447
+ end
448
+ end
449
+
450
+ def comp(name, *args)
451
+ @used_dependencies['components'] << name.to_s
452
+ if defined?(Joys) && Joys.respond_to?(:comp)
453
+ Joys.comp(name, *args)
454
+ else
455
+ ""
456
+ end
457
+ end
458
+
459
+ def data(model_name)
460
+ @used_dependencies['data'] ||= []
461
+ @used_dependencies['data'] << model_name.to_s
462
+
463
+ SSG.dependencies[:data][model_name.to_s] ||= []
464
+ SSG.dependencies[:data][model_name.to_s] << @page_file_path
465
+
466
+ if defined?(Joys::Data)
467
+ Joys::Data.query(model_name)
468
+ else
469
+ MockDataQuery.new
470
+ end
471
+ end
472
+
473
+ # Asset helper methods
474
+ def asset_path(asset_name)
475
+ domain = extract_domain_from_path(@page_file_path)
476
+ manifest_key = "#{domain}/#{asset_name}"
477
+
478
+ @asset_manifest[manifest_key] || "/assets/#{asset_name}"
479
+ end
480
+
481
+ def asset_url(asset_name, base_url = nil)
482
+ path = asset_path(asset_name)
483
+ base_url ||= @site_url || ""
484
+ base_url.chomp("/") + path
485
+ end
486
+
487
+ def paginate(collection, per_page:, &block)
488
+ raise ArgumentError, "per_page must be positive" if per_page <= 0
489
+
490
+ if collection.respond_to?(:paginate)
491
+ # Use Joys::Data pagination if available
492
+ pages = collection.paginate(per_page: per_page)
493
+ else
494
+ # Fallback for non-query collections (e.g., plain arrays)
495
+ items = Array(collection)
496
+ total_pages = (items.size.to_f / per_page).ceil
497
+ pages = items.each_slice(per_page).map.with_index do |slice, index|
498
+ Joys::Data::Page.new(slice, index + 1, total_pages, items.size)
499
+ end
500
+ end
501
+
502
+ if @capturing_pagination
503
+ @captured_paginations << {
504
+ pages: pages,
505
+ block_source: extract_block_source(block)
506
+ }
507
+ ""
508
+ else
509
+ if @current_pagination_page
510
+ yield @current_pagination_page
511
+ else
512
+ ""
513
+ end
514
+ end
515
+ end
516
+
517
+ private
518
+
519
+ def extract_domain_from_path(file_path)
520
+ # Extract domain from path like content/blog_mysite_com/index.rb -> blog_mysite_com
521
+ match = file_path.match(%r{#{Regexp.escape(@content_dir)}/([^/]+)/})
522
+ match ? match[1] : 'default'
523
+ end
524
+
525
+ def create_page_object(items, current_page, total_pages, total_items, base_page)
526
+ base_path = base_page[:url_path].chomp('/')
527
+ base_path = '/' if base_path.empty?
528
+
529
+ PageObject.new(
530
+ posts: items, # Using 'posts' for consistency with handoff doc
531
+ current_page: current_page,
532
+ total_pages: total_pages,
533
+ total_items: total_items,
534
+ prev_page: current_page > 1 ? current_page - 1 : nil,
535
+ next_page: current_page < total_pages ? current_page + 1 : nil,
536
+ prev_path: current_page > 1 ? (current_page == 2 ? base_path : "#{base_path}/page-#{current_page - 1}") : nil,
537
+ next_path: current_page < total_pages ? "#{base_path}/page-#{current_page + 1}" : nil,
538
+ is_first_page: current_page == 1,
539
+ is_last_page: current_page == total_pages
540
+ )
541
+ end
542
+
543
+ def extract_block_source(block)
544
+ source_location = block.source_location
545
+ if source_location
546
+ file, line = source_location
547
+ lines = File.readlines(file)
548
+ lines[line - 1] || "yield page"
549
+ else
550
+ "yield page"
551
+ end
552
+ end
553
+ end
554
+
555
+ class PageObject
556
+ def initialize(**attrs)
557
+ @attrs = attrs
558
+ end
559
+
560
+ def method_missing(method_name, *args)
561
+ if @attrs.key?(method_name)
562
+ @attrs[method_name]
563
+ else
564
+ super
565
+ end
566
+ end
567
+
568
+ def respond_to_missing?(method_name, include_private = false)
569
+ @attrs.key?(method_name) || super
570
+ end
571
+
572
+ def to_h
573
+ @attrs
574
+ end
575
+ end
576
+
577
+ class MockDataQuery
578
+ def method_missing(method, *args)
579
+ self
580
+ end
581
+
582
+ def all
583
+ []
584
+ end
585
+
586
+ def count
587
+ 0
588
+ end
589
+ end
590
+ end
591
+ end
592
+
593
+ module Joys
594
+ def self.build_static_site(content_dir, output_dir = 'dist', **options)
595
+ SSG.build(content_dir, output_dir, **options)
596
+ end
597
+ end
data/lib/joys/tags.rb CHANGED
@@ -111,10 +111,13 @@ module Joys
111
111
  @bf << GTS << tag << BC
112
112
  nil
113
113
  end
114
-
115
114
  define_method("#{tag}!") do |content = nil, cs: nil, **attrs, &block|
116
115
  send(tag, content, cs: cs, raw: true, **attrs, &block)
117
116
  end
117
+ define_method("#{tag}?") do |content = nil, cs: nil, **attrs, &block|
118
+ parsed_content = Joys::Config.markup_parser.call(content.to_s)
119
+ send(tag, parsed_content, cs: cs, raw: true, **attrs, &block)
120
+ end
118
121
  end
119
122
  end
120
123
  end