beachio-hammer 0.1.0.pre3 → 0.1.0.pre5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ff203a27e8f29525a3120a073279ab2e8578f9d60a49a7f1c62c7ebf1a59253
4
- data.tar.gz: e706eaee172b73d6abf8c14f7d936adb48144b2c4b2fc8ebdbf44fcfb9952f81
3
+ metadata.gz: 761f1f1873cb333406eeab947ee2ae1edde559b69fa38497810c96bdf802f58e
4
+ data.tar.gz: 6721ab8889e4129b9c886fa875b92579d359a9012852d69aaa711fa1e29c08eb
5
5
  SHA512:
6
- metadata.gz: 47a316f9de1c74ec4c16881c2663ab8aa172256264fe6e7aa66aa84d95f7d26a8082bdf521bc998bcb26f5e8bc621b8b05c6301034ead326e2dbb8271e292185
7
- data.tar.gz: 3c5cbc689cb680d0e3adf630adfc2add3f13285aebdd2f6dae258ea83deff4a7364dfe609ac816e638dab2b732ab2cf63404b9effe1d5424afb8ac182e7e35d0
6
+ metadata.gz: 23c9ab030b80757ac305dc66d4f0a35c9df394e248fa22ce62e8164d96c9b1573c4771b49ea82770574c113a7c4fd56ccfd26e294bda7f4515ede443dca28bfa
7
+ data.tar.gz: 5b670417365cbd83d7a706f3a1dc03edaadbcde2ca5cff920651446f66c790565d97a798e3a784fed847493cd62e1051cd27a0a4b23d11d67cd46014fb85314b
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- beachio-hammer (0.1.0.pre2)
4
+ beachio-hammer (0.1.0.pre5)
5
5
  json (~> 2.7)
6
6
 
7
7
  GEM
data/PUBLISH.md CHANGED
@@ -38,7 +38,17 @@ gem build beachio-hammer.gemspec
38
38
  gem push beachio-hammer-*.gem
39
39
  ```
40
40
 
41
- Prerelease pin for executor team: **`beachio-hammer` `0.1.0.pre3`**
41
+ Prerelease pin for executor team: **`beachio-hammer` `0.1.0.pre5`**
42
+
43
+ ### 0.1.0.pre5 changes (collection loops on static pages)
44
+
45
+ - Parse loop attributes (`sort="date:desc"`) when pre-processing `collections.*` loops
46
+ - Evaluate `$helpers.formatDate(...)` inside `@loop` bodies
47
+ - Fix loop condition checks for `post.category` syntax
48
+
49
+ ### 0.1.0.pre4 changes (filename digit corruption)
50
+
51
+ - Fix `@path` URL encoding: stop using `tr("%20", " ")` which mangled filenames containing `2` (e.g. `forge-2.jpg` → `forge-%20.jpg`)
42
52
 
43
53
  ### 0.1.0.pre3 changes (nested @path resolution)
44
54
 
@@ -58,7 +68,7 @@ Prerelease pin for executor team: **`beachio-hammer` `0.1.0.pre3`**
58
68
  Pin the published gem version in the executor Dockerfile:
59
69
 
60
70
  ```dockerfile
61
- RUN gem install beachio-hammer -v 0.1.0.pre3
71
+ RUN gem install beachio-hammer -v 0.1.0.pre5
62
72
  ```
63
73
 
64
74
  Build step in the agent:
@@ -45,10 +45,21 @@ module Hammer
45
45
  when "long" then time.strftime("%B %d, %Y")
46
46
  when "full" then time.strftime("%A, %B %d, %Y")
47
47
  when "iso", "iso8601" then time.utc.iso8601
48
- else time.strftime(format)
48
+ else time.strftime(swift_date_format_to_strftime(format))
49
49
  end
50
50
  end
51
51
 
52
+ def swift_date_format_to_strftime(format)
53
+ format
54
+ .gsub("yyyy", "%Y")
55
+ .gsub("MMM", "%b")
56
+ .gsub("MM", "%m")
57
+ .gsub("dd", "%d")
58
+ .gsub("HH", "%H")
59
+ .gsub("mm", "%M")
60
+ .gsub("ss", "%S")
61
+ end
62
+
52
63
  def relative_path(args)
53
64
  from = args[0].to_s
54
65
  to = args[1].to_s
@@ -237,7 +237,11 @@ module Hammer
237
237
  collection_expr = m[2].strip
238
238
  next if collection_expr.start_with?("_loop_")
239
239
 
240
- collection_value = evaluator.evaluate(collection_expr)
240
+ base_expression, attributes = parse_loop_attributes(collection_expr)
241
+ collection_value = evaluator.evaluate(base_expression)
242
+ next if collection_value.nil?
243
+
244
+ collection_value = apply_loop_attributes(collection_value, attributes) unless attributes.empty?
241
245
  items = collection_to_json_items(collection_value)
242
246
  json_array = JSON.generate(items)
243
247
  escaped = json_array.gsub("\\", "\\\\").gsub('"', '\\"').gsub("\n", "\\n")
@@ -266,11 +270,94 @@ module Hammer
266
270
  end
267
271
  when Document
268
272
  [value.to_h]
273
+ when NilClass
274
+ []
269
275
  else
270
276
  [{ "value" => value.to_s }]
271
277
  end
272
278
  end
273
279
 
280
+ def parse_loop_attributes(expression)
281
+ attributes = {}
282
+ base_expression = expression.strip
283
+ pattern = /(\w+)\s*=\s*(["'])(.*?)\2/
284
+
285
+ expression.scan(pattern) do |name, quote, value|
286
+ attributes[name] = value
287
+ end
288
+
289
+ if attributes.any?
290
+ base_expression = expression.gsub(/\s*\w+\s*=\s*(["']).*?\1/, "").strip
291
+ end
292
+
293
+ [base_expression, attributes]
294
+ end
295
+
296
+ def parse_sort_attribute(sort_value)
297
+ sort_value.split(",").filter_map do |field_spec|
298
+ field, direction = field_spec.strip.split(":", 2)
299
+ next if field.nil? || field.empty?
300
+
301
+ order = direction&.downcase
302
+ order = :desc if order == "desc" || order == "descending"
303
+ order = :asc unless order == :desc
304
+ { field: field.strip, order: order }
305
+ end
306
+ end
307
+
308
+ def apply_loop_attributes(value, attributes)
309
+ return value unless attributes["sort"]
310
+
311
+ sorts = parse_sort_attribute(attributes["sort"])
312
+ return value if sorts.empty?
313
+
314
+ documents = case value
315
+ when Collection then value.documents.dup
316
+ when Array then value.dup
317
+ else return value
318
+ end
319
+
320
+ sorts.reverse_each do |sort|
321
+ documents.sort! do |left, right|
322
+ left_value = extract_sort_value(left, sort[:field])
323
+ right_value = extract_sort_value(right, sort[:field])
324
+ comparison = compare_sort_values(left_value, right_value)
325
+ sort[:order] == :desc ? -comparison : comparison
326
+ end
327
+ end
328
+
329
+ case value
330
+ when Collection then Collection.new(value.name, documents)
331
+ else documents
332
+ end
333
+ end
334
+
335
+ def extract_sort_value(item, field)
336
+ case item
337
+ when Document
338
+ item.metadata[field] || (field == "date" ? item.date : nil)
339
+ when Hash
340
+ item[field] || item[field.to_sym]
341
+ end
342
+ end
343
+
344
+ def compare_sort_values(left, right)
345
+ left_time = sortable_time(left)
346
+ right_time = sortable_time(right)
347
+ return left_time <=> right_time if left_time && right_time
348
+
349
+ left.to_s <=> right.to_s
350
+ end
351
+
352
+ def sortable_time(value)
353
+ case value
354
+ when Time then value
355
+ when ::Date then value.to_time
356
+ when String
357
+ Document.parse_date(value)&.to_time
358
+ end
359
+ end
360
+
274
361
  def parse_json_collection(value)
275
362
  trimmed = value.strip
276
363
  return value unless trimmed.start_with?("[") || trimmed.start_with?("{")
@@ -240,7 +240,7 @@ module Hammer
240
240
  end
241
241
  item_content = process_nested_properties(item_content, item_hash, item_name)
242
242
  item_content = process_conditionals_in_loop(item_content, item_hash, item_name)
243
- item_content
243
+ process_helper_expressions_in_loop(item_content, item_hash, item_name)
244
244
  end.join
245
245
 
246
246
  result.sub!(match[0], processed)
@@ -270,6 +270,89 @@ module Hammer
270
270
  end
271
271
  end
272
272
 
273
+ def process_helper_expressions_in_loop(content, item, item_name)
274
+ content.gsub(/<!--\s*\$([^>]+?)\s*-->/) do
275
+ expr = Regexp.last_match(1).strip
276
+ next Regexp.last_match(0) unless expr.include?("helpers.") || expr.start_with?("#{item_name}.")
277
+
278
+ evaluate_loop_helper_expression(expr, item, item_name) || Regexp.last_match(0)
279
+ end
280
+ end
281
+
282
+ def evaluate_loop_helper_expression(expr, item, item_name)
283
+ if expr.include?("helpers.") && expr.include?("(")
284
+ helper_name, args = parse_helper_call(expr)
285
+ return nil unless helper_name&.start_with?("helpers.")
286
+
287
+ evaluated_args = args.map { |arg| evaluate_loop_helper_argument(arg, item, item_name) }
288
+ Content::Helpers.call(helper_name.delete_prefix("helpers."), evaluated_args)
289
+ elsif expr.start_with?("#{item_name}.")
290
+ value = expr.split(".").drop(1).reduce(item) do |obj, key|
291
+ obj.is_a?(Hash) ? (obj[key] || obj[key.to_sym]) : nil
292
+ end
293
+ value_to_string(value)
294
+ end
295
+ rescue StandardError
296
+ nil
297
+ end
298
+
299
+ def parse_helper_call(expr)
300
+ open = expr.index("(")
301
+ name = expr[0...open].strip
302
+ args_str = expr[open + 1..]
303
+ close = find_matching_paren(args_str)
304
+ args = split_helper_args(args_str[0...close])
305
+ [name, args]
306
+ end
307
+
308
+ def find_matching_paren(str)
309
+ depth = 1
310
+ str.chars.each_with_index do |ch, idx|
311
+ depth += 1 if ch == "("
312
+ if ch == ")"
313
+ depth -= 1
314
+ return idx if depth.zero?
315
+ end
316
+ end
317
+ str.length - 1
318
+ end
319
+
320
+ def split_helper_args(args_str)
321
+ args = []
322
+ current = +""
323
+ in_quotes = false
324
+ quote = nil
325
+
326
+ args_str.each_char do |ch|
327
+ if !in_quotes && (ch == '"' || ch == "'")
328
+ in_quotes = true
329
+ quote = ch
330
+ current << ch
331
+ elsif in_quotes && ch == quote
332
+ in_quotes = false
333
+ quote = nil
334
+ current << ch
335
+ elsif !in_quotes && ch == ","
336
+ args << current.strip
337
+ current = +""
338
+ else
339
+ current << ch
340
+ end
341
+ end
342
+ args << current.strip unless current.strip.empty?
343
+ args
344
+ end
345
+
346
+ def evaluate_loop_helper_argument(arg, item, item_name)
347
+ trimmed = arg.strip
348
+ if (trimmed.start_with?('"') && trimmed.end_with?('"')) ||
349
+ (trimmed.start_with?("'") && trimmed.end_with?("'"))
350
+ return trimmed[1..-2]
351
+ end
352
+
353
+ evaluate_loop_helper_expression(trimmed, item, item_name)
354
+ end
355
+
273
356
  def process_conditionals_in_loop(content, item, item_name)
274
357
  pattern = /<!--\s*@(if|unless)\s+(.+?)\s*-->(.*?)(?:<!--\s*@else\s*-->(.*?))?<!--\s*@(endif|endunless|end)\s*-->/m
275
358
  result = content.dup
@@ -288,9 +371,11 @@ module Hammer
288
371
  end
289
372
 
290
373
  def evaluate_loop_condition(condition, item, item_name)
291
- if (m = condition.match(/\A@#{Regexp.escape(item_name)}\.(.+)\z/))
374
+ if (m = condition.match(/\A@?#{Regexp.escape(item_name)}\.(.+)\z/))
292
375
  prop = m[1]
293
- val = prop.split(".").reduce(item) { |o, k| o.is_a?(Hash) ? o[k] : nil }
376
+ val = prop.split(".").reduce(item) do |obj, key|
377
+ obj.is_a?(Hash) ? (obj[key] || obj[key.to_sym]) : nil
378
+ end
294
379
  return truthy?(val)
295
380
  end
296
381
 
@@ -371,14 +456,13 @@ module Hammer
371
456
  paths = [search_path] unless index_paths.empty?
372
457
  end
373
458
 
374
- relative_path = (paths.first || search_path).tr(" ", "%20")
459
+ relative_path = paths.first || search_path
460
+ relative_path = relative_path.gsub(" ", "%20")
375
461
  relative_path = "#{relative_path}/" if preserve_slash && !relative_path.end_with?("/")
376
462
 
377
463
  @delegate&.warning(@current_file_path, "File not found: #{ref}") if paths.empty?
378
464
 
379
465
  generate_context_aware_path(relative_path, preserve_trailing_slash: preserve_slash)
380
- .tr("%20", " ")
381
- .gsub(" ", "%20")
382
466
  end
383
467
 
384
468
  def root_relative_path
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hammer
4
- VERSION = "0.1.0.pre3"
4
+ VERSION = "0.1.0.pre5"
5
5
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "blog listing page" do
6
+ let(:beachio) { File.expand_path("../../../../Beachio/Beachio", __dir__) }
7
+
8
+ it "renders sorted posts and formatted dates on export" do
9
+ skip "Beachio fixture not present locally" unless File.directory?(beachio)
10
+
11
+ site = Hammer::ProjectResolver.create_site(beachio)
12
+ builder = Hammer::Builder.new(site: site, mode: :export, clean: true)
13
+ builder.build
14
+
15
+ html = Hammer.read_text(File.join(beachio, "Build/blog/index.html"))
16
+ card_count = html.scan('class="blog-card"').length
17
+
18
+ expect(card_count).to be > 10
19
+ expect(html).to include('class="blog-card__date">03 Sep 2026</time>')
20
+ expect(html).not_to include("$helpers.formatDate")
21
+ end
22
+ end
@@ -23,4 +23,14 @@ RSpec.describe Hammer::TagParser do
23
23
 
24
24
  expect(result).to include('href="../../"')
25
25
  end
26
+
27
+ it "does not corrupt digits in image filenames when resolving @path" do
28
+ parser.current_file_path = "products/forge/index.html"
29
+ content = '<img src="<!-- @path assets/images/products/forge-2.jpg -->" />'
30
+
31
+ result = parser.send(:parse, content)
32
+
33
+ expect(result).to include('src="../../assets/images/products/forge-2.jpg"')
34
+ expect(result).not_to include("forge-%20.jpg")
35
+ end
26
36
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: beachio-hammer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre3
4
+ version: 0.1.0.pre5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Beach.io
@@ -77,6 +77,7 @@ files:
77
77
  - scripts/test-classic.rb
78
78
  - scripts/test-content.rb
79
79
  - scripts/tree-diff.rb
80
+ - spec/blog_listing_spec.rb
80
81
  - spec/encoding_spec.rb
81
82
  - spec/parity/classic_spec.rb
82
83
  - spec/parity/content_spec.rb