beachio-hammer 0.1.0.pre4 → 0.1.0.pre6

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: 3470eba02cbe963f2f12e5b4d9cc94c2cb7d2b770ada0e7ca90de6c3a57e74a8
4
- data.tar.gz: 336084edab3f5528a9dc6171b8b5fb363e1e6d05ab456d10fb15905583752b2e
3
+ metadata.gz: f3b4a0b499b230ddf29d32eec63d87dc4e6ceac09bbfdcbc11b076532dad79a6
4
+ data.tar.gz: 6eb175a810aae7ad23c4ffef79f770cb9201083f3ed4466b9c3ed5fefb975c4c
5
5
  SHA512:
6
- metadata.gz: 2dd5e1d16f62a38b7bde783e30313ef54d10c17dd001abbcd1a9ddf374f1255dac772f7f312a0312c28d44da1c8d781dd312d1b915ebe4af95f7a7a22bebd41d
7
- data.tar.gz: 49089ebfa731d5c58a7f5d91634cf726eb44c320c7dc84782fcf374b4ef9a4eec97745533cb962c8c3ef2f6fbbf7be575b170f8b63c32fa9a548dc55b1bf4970
6
+ metadata.gz: 1b6f05d60825d07e45d3c1055b76b4e9e1155c664ae7de008602c1072fbddec96d19818aad8496f7955eb1f3b4c7c9363cc6ab87ab8990e77dded5a4bd9e00c7
7
+ data.tar.gz: 036de2226fd8f9708baf2ff5a46c9fe58f23b7d54e61331ca55aa87f55fb2b0cf05c351869d6fc7a48091104068aebf18a0fa8b3e984115cfb175a5900d67d5b
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- beachio-hammer (0.1.0.pre4)
4
+ beachio-hammer (0.1.0.pre6)
5
5
  json (~> 2.7)
6
6
 
7
7
  GEM
data/PUBLISH.md CHANGED
@@ -38,7 +38,20 @@ 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.pre4`**
41
+ Prerelease pin for executor team: **`beachio-hammer` `0.1.0.pre6`**
42
+
43
+ ### 0.1.0.pre6 changes (`hammer check`)
44
+
45
+ - Add `hammer check` for content validation (Swift CLI parity)
46
+ - Flags: `--project`, `--strict`, `--format json|human`
47
+ - Rule IDs: `CONFIG_MISSING`, `CONFIG_INVALID`, `COLLECTION_DIR_MISSING`, `TEMPLATE_MISSING`, `PARSE_ERROR`, `REQUIRED_FIELD_MISSING`, `SLUG_MISSING`, `PROJECT_NOT_FOUND`
48
+ - Agent QC: `hammer check --strict --format json`
49
+
50
+ ### 0.1.0.pre5 changes (collection loops on static pages)
51
+
52
+ - Parse loop attributes (`sort="date:desc"`) when pre-processing `collections.*` loops
53
+ - Evaluate `$helpers.formatDate(...)` inside `@loop` bodies
54
+ - Fix loop condition checks for `post.category` syntax
42
55
 
43
56
  ### 0.1.0.pre4 changes (filename digit corruption)
44
57
 
@@ -62,16 +75,17 @@ Prerelease pin for executor team: **`beachio-hammer` `0.1.0.pre4`**
62
75
  Pin the published gem version in the executor Dockerfile:
63
76
 
64
77
  ```dockerfile
65
- RUN gem install beachio-hammer -v 0.1.0.pre4
78
+ RUN gem install beachio-hammer -v 0.1.0.pre6
66
79
  ```
67
80
 
68
- Build step in the agent:
81
+ Validate and build in the agent:
69
82
 
70
83
  ```bash
84
+ hammer check --project "$WORKSPACE" --strict --format json
71
85
  hammer build --project "$WORKSPACE" --mode export --format json --clean
72
86
  ```
73
87
 
74
- ### CLI contract
88
+ ### CLI contract — `hammer build`
75
89
 
76
90
  | Flag | Default | Notes |
77
91
  |------|---------|-------|
@@ -94,6 +108,29 @@ hammer build --project "$WORKSPACE" --mode export --format json --clean
94
108
  }
95
109
  ```
96
110
 
111
+ ### CLI contract — `hammer check`
112
+
113
+ | Flag | Default | Notes |
114
+ |------|---------|-------|
115
+ | `--project` | cwd (walk-up for markers) | Project root |
116
+ | `--strict` | off | Treat warnings as errors |
117
+ | `--format` | `human` | `json` for agent parsing |
118
+
119
+ **Exit codes:** `0` pass (or warnings without `--strict`), `1` check failures, `2` project/config error, `3` internal.
120
+
121
+ **JSON stdout** (`--format json`):
122
+
123
+ ```json
124
+ {
125
+ "status": "pass",
126
+ "errors": [{ "ruleId", "message", "file", "suggestion" }],
127
+ "warnings": [{ "ruleId", "message", "file", "suggestion" }],
128
+ "stats": { "filesChecked", "durationMs" }
129
+ }
130
+ ```
131
+
132
+ **Rule IDs:** `CONFIG_MISSING` (warn), `CONFIG_INVALID`, `COLLECTION_DIR_MISSING`, `TEMPLATE_MISSING`, `PARSE_ERROR`, `REQUIRED_FIELD_MISSING`, `SLUG_MISSING` (warn), `PROJECT_NOT_FOUND` (JSON `status: "error"`, exit 2).
133
+
97
134
  ### Parity fixtures
98
135
 
99
136
  | Fixture | Path | Files |
@@ -23,6 +23,7 @@ require_relative "hammer/content/page_generator"
23
23
  require_relative "hammer/content/builder"
24
24
  require_relative "hammer/builder"
25
25
  require_relative "hammer/build_command"
26
+ require_relative "hammer/check_command"
26
27
  require_relative "hammer/cli"
27
28
 
28
29
  module Hammer
data/lib/hammer/cli.rb CHANGED
@@ -2,13 +2,17 @@
2
2
 
3
3
  module Hammer
4
4
  module CLI
5
+ KNOWN_COMMANDS = %w[build check].freeze
6
+
5
7
  module_function
6
8
 
7
9
  def run(argv)
8
- command = argv.first == "build" ? argv.shift : "build"
10
+ command = resolve_command(argv)
9
11
  case command
10
12
  when "build"
11
13
  BuildCommand.run(argv)
14
+ when "check"
15
+ CheckCommand.run(argv)
12
16
  else
13
17
  warn "Unknown command: #{command}"
14
18
  2
@@ -23,5 +27,13 @@ module Hammer
23
27
  warn "Build failed: #{e.message}"
24
28
  1
25
29
  end
30
+
31
+ def resolve_command(argv)
32
+ return "build" if argv.empty?
33
+ return argv.shift if KNOWN_COMMANDS.include?(argv.first)
34
+ return "build" if argv.first.start_with?("-")
35
+
36
+ argv.first
37
+ end
26
38
  end
27
39
  end
@@ -22,6 +22,24 @@ module Hammer
22
22
  output&.dig("filename") || output_path
23
23
  end
24
24
 
25
+ def validate_required_fields(metadata)
26
+ return [] unless fields
27
+
28
+ missing = []
29
+ fields.each do |field_name, field_config|
30
+ next unless field_config.required == true
31
+
32
+ unless metadata.key?(field_name)
33
+ missing << field_name
34
+ next
35
+ end
36
+
37
+ value = metadata[field_name]
38
+ missing << field_name if value.is_a?(String) && value.empty?
39
+ end
40
+ missing
41
+ end
42
+
25
43
  def self.from_hash(hash)
26
44
  fields = (hash["fields"] || {}).transform_values { |f| FieldConfig.from_hash(f) }
27
45
  new(
@@ -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?("{")
@@ -29,5 +29,56 @@ module Hammer
29
29
  puts "Build finished in #{duration.round(2)}s — #{file_count} files (#{build_size})"
30
30
  end
31
31
  end
32
+
33
+ def format_check_result(status:, errors:, warnings:, files_checked:, duration_ms:, format:)
34
+ if format == :json
35
+ payload = {
36
+ "status" => status,
37
+ "errors" => errors.map { |e| check_issue_hash(e) },
38
+ "warnings" => warnings.map { |w| check_issue_hash(w) },
39
+ "stats" => {
40
+ "filesChecked" => files_checked,
41
+ "durationMs" => duration_ms
42
+ }
43
+ }
44
+ puts JSON.pretty_generate(payload)
45
+ return
46
+ end
47
+
48
+ puts ""
49
+ if status == "pass"
50
+ puts "\u2714 #{files_checked} files validated"
51
+ else
52
+ puts "\u2716 #{errors.length} error#{errors.length == 1 ? '' : 's'}"
53
+ end
54
+
55
+ errors.each do |error|
56
+ puts ""
57
+ puts "[#{error[:rule_id]}]"
58
+ puts error[:message]
59
+ puts " #{error[:file]}"
60
+ puts " Suggestion: #{error[:suggestion]}" if error[:suggestion]
61
+ end
62
+
63
+ unless warnings.empty?
64
+ puts ""
65
+ puts "\u26A0 #{warnings.length} warning#{warnings.length == 1 ? '' : 's'}"
66
+ warnings.each do |warning|
67
+ puts " [#{warning[:rule_id]}] #{warning[:message]}"
68
+ puts " #{warning[:file]}"
69
+ end
70
+ end
71
+ puts ""
72
+ end
73
+
74
+ def check_issue_hash(issue)
75
+ hash = {
76
+ "ruleId" => issue[:rule_id],
77
+ "message" => issue[:message],
78
+ "file" => issue[:file]
79
+ }
80
+ hash["suggestion"] = issue[:suggestion] if issue[:suggestion]
81
+ hash
82
+ end
32
83
  end
33
84
  end
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hammer
4
- VERSION = "0.1.0.pre4"
4
+ VERSION = "0.1.0.pre6"
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
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.pre4
4
+ version: 0.1.0.pre6
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