beachio-hammer 0.1.0.pre1

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.
@@ -0,0 +1,569 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+
6
+ module Hammer
7
+ class TagParser
8
+ attr_reader :variables, :todos
9
+ attr_accessor :current_file_path
10
+
11
+ def initialize(source_directory:, parent_variables: nil, build_mode: :normal, delegate: nil,
12
+ content_collections: {}, content_config: nil, skip_path_prefixes: [])
13
+ @source_directory = source_directory
14
+ @skip_path_prefixes = skip_path_prefixes
15
+ @parent_variables = parent_variables || {}
16
+ @variables = {}
17
+ @build_mode = build_mode
18
+ @delegate = delegate
19
+ @content_collections = content_collections
20
+ @content_config = content_config
21
+ @processed_includes = Set.new
22
+ @todos = []
23
+ @current_file_path = nil
24
+ end
25
+
26
+ def process_file(relative_path, output_path_for_styling: nil)
27
+ @current_file_path = output_path_for_styling || relative_path
28
+ content = File.read(File.join(@source_directory, relative_path))
29
+ process_file_content(content, relative_path, output_path_for_styling)
30
+ end
31
+
32
+ def process_file_content(content, relative_path, output_path_for_styling = nil)
33
+ all_vars = @parent_variables.merge(@variables)
34
+ parser = self.class.new(
35
+ source_directory: @source_directory,
36
+ parent_variables: all_vars,
37
+ build_mode: @build_mode,
38
+ delegate: @delegate,
39
+ content_collections: @content_collections,
40
+ content_config: @content_config,
41
+ skip_path_prefixes: @skip_path_prefixes
42
+ )
43
+ parser.current_file_path = output_path_for_styling || relative_path
44
+ result = parser.parse(content)
45
+ @variables.merge!(parser.variables)
46
+ @todos.concat(parser.todos)
47
+ result
48
+ end
49
+
50
+ def parse(content)
51
+ result = escape_code_examples(content)
52
+ result = process_variable_declarations(result)
53
+ result = process_includes(result)
54
+ result = replace_variables(result)
55
+ result = process_loops_and_conditionals(result)
56
+ result = process_paths(result)
57
+ result = process_navigation_helpers(result)
58
+ result = @build_mode == :normal ? process_reload_tag(result) : strip_reload_tag(result)
59
+ result = process_stylesheets(result)
60
+ result = process_javascript(result)
61
+ uncomment_hammer_tags(result)
62
+ end
63
+
64
+ def process_variable_declarations(content)
65
+ content.gsub(/<!--\s*@var\s+([^=]+?)\s*=\s*(.*?)\s*-->/m) do
66
+ name = Regexp.last_match(1).strip
67
+ value = unquote_var_value(Regexp.last_match(2).strip)
68
+ @variables[name] = value
69
+ ""
70
+ end
71
+ end
72
+
73
+ def unquote_var_value(value)
74
+ if value.start_with?('"') && value.end_with?('"')
75
+ value = value[1..-2]
76
+ value = value.gsub("--&gt;", "-->")
77
+ .gsub("\\n", "\n")
78
+ .gsub("\\r", "\r")
79
+ .gsub("\\t", "\t")
80
+ .gsub('\\"', '"')
81
+ .gsub("\\\\", "\\")
82
+ end
83
+ value
84
+ end
85
+
86
+ def skip_scan_path?(rel)
87
+ return true if rel.start_with?("Build/", ".")
88
+ return true if @skip_path_prefixes.any? { |prefix| rel == prefix.chomp("/") || rel.start_with?(prefix) }
89
+
90
+ false
91
+ end
92
+
93
+ def find_include_file(filename)
94
+ possible_names = ["_#{filename}.html", "#{filename}.html", "_#{filename}", filename]
95
+ common_dirs = %w[includes _includes partials _partials snippets _snippets]
96
+
97
+ common_dirs.each do |dir|
98
+ possible_names.each do |name|
99
+ path = "#{dir}/#{name}"
100
+ return path if File.file?(File.join(@source_directory, path))
101
+ end
102
+ end
103
+
104
+ possible_names.each do |name|
105
+ return name if File.file?(File.join(@source_directory, name))
106
+ end
107
+
108
+ found = []
109
+ Dir.glob(File.join(@source_directory, "**", "*")).each do |f|
110
+ next unless File.file?(f)
111
+ rel = f.delete_prefix("#{@source_directory}/")
112
+ next if rel.start_with?("Build/", ".")
113
+ next if @skip_path_prefixes.any? { |prefix| rel == prefix.chomp("/") || rel.start_with?(prefix) }
114
+
115
+ found << rel if possible_names.include?(File.basename(f))
116
+ end
117
+
118
+ return [nil, nil] if found.empty?
119
+
120
+ found.sort_by! do |path|
121
+ in_dir = path.match?(/includes|partials|snippets/i) ? 0 : 1
122
+ underscore = File.basename(path).start_with?("_") ? 0 : 1
123
+ [in_dir, underscore, path]
124
+ end
125
+ [found.first, nil]
126
+ end
127
+
128
+ private
129
+
130
+ def escape_code_examples(content)
131
+ content.gsub(/<code class="hammer-code">((?:(?!<code class="hammer-code").)*?)<\/code>/m) do
132
+ inner = Regexp.last_match(1)
133
+ next Regexp.last_match(0) if inner.include?("&lt;") || inner.include?("&gt;")
134
+
135
+ escaped = inner.gsub("<", "&lt;").gsub(">", "&gt;")
136
+ %(<code class="hammer-code">#{escaped}</code>)
137
+ end
138
+ end
139
+
140
+ def get_variable(name)
141
+ @variables[name] || @parent_variables[name]
142
+ end
143
+
144
+ def process_includes(content)
145
+ result = content.dup
146
+ count = 0
147
+ include_pattern = /<!--\s*@include\s+([^\s>]+)\s*-->/
148
+
149
+ while (match = result.match(include_pattern)) && count < 100
150
+ count += 1
151
+ include_ref = match[1].strip
152
+ if include_ref.start_with?("$")
153
+ var_name = include_ref[1..]
154
+ include_ref = get_variable(var_name) || include_ref
155
+ end
156
+
157
+ path, = find_include_file(include_ref)
158
+ unless path
159
+ @delegate&.warning(@current_file_path, "Include not found: #{include_ref}")
160
+ result.sub!(match[0], "")
161
+ next
162
+ end
163
+
164
+ if @processed_includes.include?(path)
165
+ result.sub!(match[0], "")
166
+ next
167
+ end
168
+
169
+ @processed_includes << path
170
+ included = File.read(File.join(@source_directory, path))
171
+ child = self.class.new(
172
+ source_directory: @source_directory,
173
+ parent_variables: @parent_variables.merge(@variables),
174
+ build_mode: @build_mode,
175
+ delegate: @delegate,
176
+ content_collections: @content_collections,
177
+ content_config: @content_config,
178
+ skip_path_prefixes: @skip_path_prefixes
179
+ )
180
+ child.current_file_path = @current_file_path
181
+ processed = child.parse(included)
182
+ @variables.merge!(child.variables)
183
+ result.sub!(match[0], processed)
184
+ end
185
+
186
+ result
187
+ end
188
+
189
+ def replace_variables(content)
190
+ result = content.dup
191
+ control_tags = %w[if unless else endif endunless loop endloop end]
192
+ result.gsub!(/<!--\s*@([a-zA-Z_][a-zA-Z0-9_-]*)\s*-->/) do
193
+ name = Regexp.last_match(1)
194
+ next Regexp.last_match(0) if control_tags.include?(name)
195
+
196
+ get_variable(name) || (@delegate&.warning(@current_file_path, "Variable not set: #{name}"); "")
197
+ end
198
+ result.gsub!(/<!--\s*\$([a-zA-Z_][a-zA-Z0-9_-]*)\s*-->/) do
199
+ name = Regexp.last_match(1)
200
+ get_variable(name) || ""
201
+ end
202
+ result
203
+ end
204
+
205
+ def process_loops_and_conditionals(content)
206
+ result = process_loops(content)
207
+ process_conditionals(result)
208
+ end
209
+
210
+ def process_loops(content)
211
+ result = content.dup
212
+ loop_pattern = /<!--\s*@loop\s+(.+?)\s*-->(.*?)<!--\s*@endloop\s*-->/m
213
+ count = 0
214
+
215
+ while (match = result.match(loop_pattern)) && count < 1000
216
+ count += 1
217
+ loop_tag = match[1]
218
+ template = match[2]
219
+ item_name, array_name = loop_tag.split(/\s+in\s+/, 2).map(&:strip)
220
+ array_str = get_variable(array_name)
221
+
222
+ unless array_str
223
+ result.sub!(match[0], "")
224
+ next
225
+ end
226
+
227
+ begin
228
+ json_str = array_str.gsub('\\"', '"').gsub("\\n", "\n")
229
+ array_data = JSON.parse(json_str)
230
+ rescue JSON::ParserError
231
+ result.sub!(match[0], "")
232
+ next
233
+ end
234
+
235
+ processed = array_data.map do |item|
236
+ item_content = template.dup
237
+ item_hash = item.is_a?(Hash) ? item.transform_keys(&:to_s) : { "value" => item.to_s }
238
+ item_hash.each do |key, value|
239
+ item_content.gsub!("@#{item_name}.#{key}", value_to_string(value))
240
+ end
241
+ item_content = process_nested_properties(item_content, item_hash, item_name)
242
+ item_content = process_conditionals_in_loop(item_content, item_hash, item_name)
243
+ item_content
244
+ end.join
245
+
246
+ result.sub!(match[0], processed)
247
+ end
248
+
249
+ result
250
+ end
251
+
252
+ def value_to_string(value)
253
+ case value
254
+ when String then value
255
+ when TrueClass, FalseClass then value.to_s
256
+ when Numeric then value.to_s
257
+ when NilClass then ""
258
+ when Hash then value["value"].to_s
259
+ else value.to_s
260
+ end
261
+ end
262
+
263
+ def process_nested_properties(content, item, item_name)
264
+ content.gsub(/@#{Regexp.escape(item_name)}\.([a-zA-Z_][a-zA-Z0-9_.-]*)/) do
265
+ path = Regexp.last_match(1).split(".")
266
+ val = path.reduce(item) do |obj, key|
267
+ obj.is_a?(Hash) ? obj[key] : nil
268
+ end
269
+ value_to_string(val)
270
+ end
271
+ end
272
+
273
+ def process_conditionals_in_loop(content, item, item_name)
274
+ pattern = /<!--\s*@(if|unless)\s+(.+?)\s*-->(.*?)(?:<!--\s*@else\s*-->(.*?))?<!--\s*@(endif|endunless|end)\s*-->/m
275
+ result = content.dup
276
+
277
+ while (match = result.match(pattern))
278
+ type = match[1]
279
+ condition = match[2].strip
280
+ true_content = match[3]
281
+ false_content = match[4] || ""
282
+ met = evaluate_loop_condition(condition, item, item_name)
283
+ met = !met if type == "unless"
284
+ result.sub!(match[0], met ? true_content : false_content)
285
+ end
286
+
287
+ result
288
+ end
289
+
290
+ def evaluate_loop_condition(condition, item, item_name)
291
+ if (m = condition.match(/\A@#{Regexp.escape(item_name)}\.(.+)\z/))
292
+ prop = m[1]
293
+ val = prop.split(".").reduce(item) { |o, k| o.is_a?(Hash) ? o[k] : nil }
294
+ return truthy?(val)
295
+ end
296
+
297
+ if condition.end_with?(".empty?")
298
+ var = condition.sub(".empty?", "")
299
+ val = get_variable(var)
300
+ return val.nil? || val.empty?
301
+ end
302
+
303
+ val = get_variable(condition)
304
+ truthy?(val)
305
+ end
306
+
307
+ def truthy?(value)
308
+ case value
309
+ when nil then false
310
+ when String then !value.empty? && value.downcase != "false" && value != "0"
311
+ when Array then !value.empty?
312
+ when FalseClass then false
313
+ else true
314
+ end
315
+ end
316
+
317
+ def process_conditionals(content)
318
+ result = content.dup
319
+ pattern = /<!--\s*@(if|unless)\s+(.+?)\s*-->(.*?)(?:<!--\s*@else\s*-->(.*?))?(?:<!--\s*@end(?:if|unless|\1)?\s*-->|<!--\s*@end\s*-->)/m
320
+
321
+ while (match = result.match(pattern))
322
+ type = match[1]
323
+ condition = match[2].strip
324
+ true_content = match[3]
325
+ false_content = match[4] || ""
326
+ met = if condition.end_with?(".empty?")
327
+ var = condition.sub(".empty?", "")
328
+ val = get_variable(var)
329
+ begin
330
+ arr = JSON.parse(val.gsub('\\"', '"'))
331
+ arr.empty?
332
+ rescue StandardError
333
+ val.nil? || val.empty?
334
+ end
335
+ else
336
+ truthy?(get_variable(condition))
337
+ end
338
+ met = !met if type == "unless"
339
+ result.sub!(match[0], met ? true_content : false_content)
340
+ end
341
+
342
+ result
343
+ end
344
+
345
+ def process_paths(content)
346
+ content.gsub(/<!--\s*@path\s+([^>]+?)\s*-->/) do
347
+ ref = Regexp.last_match(1).strip
348
+ if ref.start_with?("$")
349
+ ref = get_variable(ref[1..]) || ref
350
+ end
351
+
352
+ if ref == "/"
353
+ generate_context_aware_path(".")
354
+ else
355
+ preserve_slash = ref.end_with?("/")
356
+ filename = preserve_slash ? ref.chomp("/") : ref
357
+ paths, error = find_file(File.basename(filename))
358
+ raise TagError, error if error
359
+
360
+ if paths.empty?
361
+ @delegate&.warning(@current_file_path, "File not found: #{ref}")
362
+ ref
363
+ else
364
+ path = paths.first
365
+ path = "#{path}/" if preserve_slash
366
+ generate_context_aware_path(path).gsub("%20", " ").gsub(" ", "%20")
367
+ end
368
+ end
369
+ end
370
+ end
371
+
372
+ def find_file(filename)
373
+ found = []
374
+ dirs = %w[assets/img assets/images assets/media images img media]
375
+ dirs.each do |dir|
376
+ path = "#{dir}/#{filename}"
377
+ found << path if File.exist?(File.join(@source_directory, path))
378
+ end
379
+ found << filename if File.exist?(File.join(@source_directory, filename))
380
+
381
+ if found.empty? || found.length > 1
382
+ Dir.glob(File.join(@source_directory, "**", "*")).each do |f|
383
+ next unless File.file?(f)
384
+ rel = f.delete_prefix("#{@source_directory}/")
385
+ next if skip_scan_path?(rel)
386
+
387
+ found << rel if File.basename(f) == filename && !found.include?(rel)
388
+ end
389
+ end
390
+
391
+ found.uniq!
392
+ return [[], "Multiple files found with name '#{filename}': #{found.join(', ')}"] if found.length > 1
393
+
394
+ [found, nil]
395
+ end
396
+
397
+ def generate_context_aware_path(file_path)
398
+ return "./#{file_path}" unless @current_file_path
399
+
400
+ path_for_processing = file_path.end_with?("/") ? file_path.chomp("/") : file_path
401
+ current_dir = File.dirname(@current_file_path).split("/").reject { |p| p == "." }
402
+ target_parts = path_for_processing.split("/")
403
+ common = 0
404
+ common += 1 while common < current_dir.length && common < target_parts.length && current_dir[common] == target_parts[common]
405
+
406
+ levels_up = current_dir.length - common
407
+ remaining = target_parts[common..]
408
+ parts = ([".."] * levels_up) + remaining
409
+
410
+ if parts.empty?
411
+ "./#{target_parts.last}"
412
+ elsif levels_up.zero? && remaining.length == 1
413
+ "./#{remaining[0]}"
414
+ else
415
+ parts.join("/")
416
+ end.tap { |r| r << "/" if file_path.end_with?("/") && !r.end_with?("/") }
417
+ end
418
+
419
+ def process_navigation_helpers(content)
420
+ return content unless @current_file_path
421
+
422
+ current = normalize_nav_path(@current_file_path)
423
+ content.gsub(/<a\s+([^>]*?)href\s*=\s*["']([^"'#][^"']*)["']([^>]*)>/i) do
424
+ before = Regexp.last_match(1)
425
+ href = Regexp.last_match(2)
426
+ after = Regexp.last_match(3)
427
+ next Regexp.last_match(0) if href.start_with?("http", "//", "#")
428
+
429
+ normalized_href = normalize_nav_path(href, base: File.dirname(@current_file_path))
430
+ classes = []
431
+ if normalized_href == current
432
+ classes << "current"
433
+ elsif index_page_match?(normalized_href, current)
434
+ classes << "current-parent"
435
+ end
436
+
437
+ next Regexp.last_match(0) if classes.empty?
438
+
439
+ tag = "<a #{before}href=\"#{href}\"#{after}>"
440
+ if tag.match?(/class\s*=/)
441
+ tag.sub(/class\s*=\s*["']([^"']*)["']/) { "class=\"#{Regexp.last_match(1)} #{classes.join(' ')}\"" }
442
+ else
443
+ tag.sub("<a ", "<a class=\"#{classes.join(' ')}\" ")
444
+ end
445
+ end
446
+ end
447
+
448
+ def normalize_nav_path(path, base: nil)
449
+ p = path.dup
450
+ p = File.expand_path(p, File.join(@source_directory, base)) if base && !p.start_with?("/")
451
+ p = p.delete_prefix(@source_directory).delete_prefix("/")
452
+ p = p.sub(%r{/index\.html\z}, "/")
453
+ p = p.sub(/\.html\z/, "")
454
+ p = "/" if p.empty? || p == "index"
455
+ p.start_with?("/") ? p : "/#{p}"
456
+ end
457
+
458
+ def index_page_match?(href, current)
459
+ href.end_with?("/") && current.start_with?(href.chomp("/"))
460
+ end
461
+
462
+ def strip_reload_tag(content)
463
+ content.gsub(/<!--\s*@reload\s*-->\s*/m, "")
464
+ end
465
+
466
+ def process_reload_tag(content)
467
+ script = <<~SCRIPT
468
+ <script>
469
+ (function(){var s=document.createElement('script');s.src='';var ws=new WebSocket('ws://localhost:35353');ws.onmessage=function(){location.reload()};})();
470
+ </script>
471
+ SCRIPT
472
+ content.gsub(/<!--\s*@reload\s*-->/, script)
473
+ end
474
+
475
+ def process_stylesheets(content)
476
+ result = content.dup
477
+ pattern = /<!--\s*@stylesheet\s+([^>]+?)\s*-->/
478
+
479
+ while (match = result.match(pattern))
480
+ files = match[1].split.map(&:strip)
481
+ links = files.map do |file|
482
+ path, = find_stylesheet(file)
483
+ raise TagError, "Stylesheet not found: #{file}" unless path
484
+
485
+ %(<link rel="stylesheet" href="#{generate_context_aware_path(path)}">)
486
+ end
487
+ result.sub!(match[0], links.join("\n") + "\n")
488
+ end
489
+
490
+ result
491
+ end
492
+
493
+ def find_stylesheet(filename)
494
+ names = filename.end_with?(".css") ? [filename, filename.sub(/\.css\z/i, "")] : [filename, "#{filename}.css"]
495
+ dirs = ["css", "assets/css", "styles", "assets/styles", "assets", ""]
496
+ found = []
497
+
498
+ dirs.each do |dir|
499
+ names.each do |name|
500
+ path = dir.empty? ? name : "#{dir}/#{name}"
501
+ found << path if File.file?(File.join(@source_directory, path))
502
+ end
503
+ end
504
+
505
+ if found.empty?
506
+ Dir.glob(File.join(@source_directory, "**", "*")).each do |f|
507
+ next unless File.file?(f)
508
+ rel = f.delete_prefix("#{@source_directory}/")
509
+ next if skip_scan_path?(rel) || File.basename(f).start_with?("_")
510
+
511
+ found << rel if names.include?(File.basename(f))
512
+ end
513
+ end
514
+
515
+ return [nil, "not found"] if found.empty?
516
+
517
+ found.sort_by! { |p| [p.match?(/css|styles/i) ? 0 : 1, p] }
518
+ [found.first, found.length > 1 ? "multiple" : nil]
519
+ end
520
+
521
+ def process_javascript(content)
522
+ result = content.dup
523
+ pattern = /<!--\s*@javascript\s+([^>]+?)\s*-->/
524
+
525
+ while (match = result.match(pattern))
526
+ files = match[1].split.map(&:strip)
527
+ tags = files.map do |file|
528
+ path, = find_javascript(file)
529
+ raise TagError, "JavaScript not found: #{file}" unless path
530
+
531
+ %(<script src="#{generate_context_aware_path(path)}"></script>)
532
+ end
533
+ result.sub!(match[0], tags.join("\n") + "\n")
534
+ end
535
+
536
+ result
537
+ end
538
+
539
+ def find_javascript(filename)
540
+ names = filename.end_with?(".js") ? [filename, filename.sub(/\.js\z/i, "")] : [filename, "#{filename}.js"]
541
+ dirs = %w[js assets/js scripts assets/scripts javascript assets/javascript assets ""]
542
+ found = []
543
+
544
+ dirs.each do |dir|
545
+ names.each do |name|
546
+ path = dir.empty? ? name : "#{dir}/#{name}"
547
+ found << path if File.file?(File.join(@source_directory, path))
548
+ end
549
+ end
550
+
551
+ return [nil, "not found"] if found.empty?
552
+
553
+ found.sort_by! { |p| [p.match?(/js|script/i) ? 0 : 1, p] }
554
+ [found.first, nil]
555
+ end
556
+
557
+ def uncomment_hammer_tags(content)
558
+ content.gsub(/(<!--\s*)(@@|\$\$)([^>]+?)(-->)/) do
559
+ prefix = Regexp.last_match(1)
560
+ symbol = Regexp.last_match(2)
561
+ body = Regexp.last_match(3)
562
+ suffix = Regexp.last_match(4)
563
+ single = symbol.start_with?("@") ? "@" : "$"
564
+ tag = "#{prefix}#{single}#{body}#{suffix}"
565
+ tag.gsub("<", "&lt;").gsub(">", "&gt;")
566
+ end
567
+ end
568
+ end
569
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hammer
4
+ VERSION = "0.1.0.pre1"
5
+ end
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
5
+
6
+ capture() {
7
+ local project="$1"
8
+ echo "Capturing golden Build/ for ${project}..."
9
+ hammer build --project "${ROOT}/Test Projects/${project}" --mode export --clean
10
+ }
11
+
12
+ capture "classic-fixture"
13
+ capture "content-generation-test"
14
+
15
+ echo "Golden Build/ trees updated."
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "beachio-hammer"
5
+ require "fileutils"
6
+
7
+ project = File.expand_path("../../Test Projects/classic-fixture", __dir__)
8
+ output = File.join(project, "Build-gem")
9
+ FileUtils.rm_rf(output)
10
+ Hammer::Builder.new(
11
+ site: Hammer::ProjectResolver.create_site(project, output: output),
12
+ mode: :export,
13
+ clean: true
14
+ ).build
15
+
16
+ golden = File.read(File.join(project, "Build/assets/css/styles.css"))
17
+ actual = File.read(File.join(output, "assets/css/styles.css"))
18
+ g_url = golden[/url\("[^"]+"\)/]
19
+ a_url = actual[/url\("[^"]+"\)/]
20
+ puts "golden count: #{g_url.count('../img/')}"
21
+ puts "actual count: #{a_url.count('../img/')}"
22
+ puts "css match: #{golden == actual}"
23
+ exit system("ruby", File.join(__dir__, "tree-diff.rb"), File.join(project, "Build"), output)
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "beachio-hammer"
5
+ require "fileutils"
6
+ require "timeout"
7
+
8
+ project = File.expand_path("../../Test Projects/content-generation-test", __dir__)
9
+ output = File.join(project, "Build-gem")
10
+ FileUtils.rm_rf(output)
11
+
12
+ Timeout.timeout(60) do
13
+ Hammer::Builder.new(
14
+ site: Hammer::ProjectResolver.create_site(project, output: output),
15
+ mode: :export,
16
+ clean: true
17
+ ).build
18
+ end
19
+
20
+ exit system("ruby", File.join(__dir__, "tree-diff.rb"), File.join(project, "Build"), output)
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+
6
+ def normalize_sitemap(content)
7
+ content.gsub(/<lastmod>[^<]+<\/lastmod>\s*/m, "")
8
+ end
9
+
10
+ def collect_files(dir)
11
+ files = {}
12
+ return files unless File.directory?(dir)
13
+
14
+ Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
15
+ next unless File.file?(path)
16
+ next if File.basename(path) == ".DS_Store"
17
+
18
+ rel = path.delete_prefix("#{dir}/")
19
+ next if rel.start_with?(".hammer-cache/")
20
+ next if rel.include?("/.hammer-cache/")
21
+
22
+ files[rel] = path
23
+ end
24
+ files
25
+ end
26
+
27
+ def file_equal?(golden_path, actual_path)
28
+ g = File.binread(golden_path)
29
+ a = File.binread(actual_path)
30
+
31
+ if File.basename(golden_path) == "sitemap.xml"
32
+ return normalize_sitemap(g) == normalize_sitemap(a)
33
+ end
34
+
35
+ g == a
36
+ end
37
+
38
+ golden_dir = ARGV[0]
39
+ actual_dir = ARGV[1]
40
+
41
+ unless golden_dir && actual_dir && File.directory?(golden_dir) && File.directory?(actual_dir)
42
+ warn "Usage: tree-diff.rb GOLDEN_BUILD_DIR ACTUAL_BUILD_DIR"
43
+ exit 2
44
+ end
45
+
46
+ golden = collect_files(golden_dir)
47
+ actual = collect_files(actual_dir)
48
+ missing = golden.keys - actual.keys
49
+ extra = actual.keys - golden.keys
50
+ mismatch = []
51
+
52
+ (golden.keys & actual.keys).each do |rel|
53
+ mismatch << rel unless file_equal?(golden[rel], actual[rel])
54
+ end
55
+
56
+ if missing.empty? && extra.empty? && mismatch.empty?
57
+ puts "Trees match (#{golden.length} files)"
58
+ exit 0
59
+ end
60
+
61
+ warn "Tree diff failed:"
62
+ missing.each { |f| warn " missing: #{f}" }
63
+ extra.each { |f| warn " extra: #{f}" }
64
+ mismatch.each { |f| warn " content mismatch: #{f}" }
65
+ exit 1