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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hammer
4
+ module Content
5
+ class Document
6
+ attr_reader :file_path, :body, :content_type, :collection
7
+ attr_accessor :metadata
8
+
9
+ def initialize(file_path:, metadata:, body:, content_type:, collection:)
10
+ @file_path = file_path
11
+ @metadata = metadata
12
+ @body = body
13
+ @content_type = content_type
14
+ @collection = collection
15
+ end
16
+
17
+ def slug
18
+ if (s = metadata["slug"]).is_a?(String) && !s.empty?
19
+ return self.class.slugify(s)
20
+ end
21
+ if (title = metadata["title"]).is_a?(String)
22
+ return self.class.slugify(title)
23
+ end
24
+
25
+ base = File.basename(file_path, ".*")
26
+ self.class.slugify(base)
27
+ end
28
+
29
+ def date
30
+ val = metadata["date"]
31
+ return val if val.is_a?(Time) || val.is_a?(Date)
32
+
33
+ self.class.parse_date(val.to_s) if val
34
+ end
35
+
36
+ def published?
37
+ return metadata["published"] if metadata.key?("published")
38
+
39
+ true
40
+ end
41
+
42
+ def self.slugify(text)
43
+ slug = text.downcase.gsub(" ", "-")
44
+ slug = slug.gsub(/[^a-z0-9-]/, "")
45
+ slug = slug.gsub(/-+/, "-")
46
+ slug = slug.gsub(/\A-+|-+\z/, "")
47
+ slug.empty? ? "untitled" : slug
48
+ end
49
+
50
+ def self.parse_date(date_string)
51
+ return nil if date_string.nil? || date_string.empty?
52
+
53
+ formats = [
54
+ "%Y-%m-%dT%H:%M:%S.%LZ",
55
+ "%Y-%m-%dT%H:%M:%SZ",
56
+ "%Y-%m-%d",
57
+ "%Y/%m/%d"
58
+ ]
59
+ formats.each do |fmt|
60
+ return Time.strptime(date_string, fmt)
61
+ rescue ArgumentError
62
+ next
63
+ end
64
+ Time.parse(date_string)
65
+ rescue ArgumentError
66
+ nil
67
+ end
68
+
69
+ def to_h
70
+ result = metadata.transform_values { |v| serialize_value(v) }
71
+ result["slug"] = slug
72
+ result["filePath"] = file_path
73
+ result["collection"] = collection
74
+ result["date"] = date&.utc&.iso8601(3) if date
75
+ result
76
+ end
77
+
78
+ def serialize_value(value)
79
+ case value
80
+ when Document
81
+ value.to_h
82
+ when Array
83
+ value.map { |v| serialize_value(v) }
84
+ when Hash
85
+ value.transform_values { |v| serialize_value(v) }
86
+ when Time, Date
87
+ value.utc.iso8601(3)
88
+ else
89
+ value
90
+ end
91
+ end
92
+ end
93
+
94
+ class Collection
95
+ attr_reader :name, :documents
96
+
97
+ def initialize(name, documents = [])
98
+ @name = name
99
+ @documents = documents
100
+ end
101
+
102
+ def add(doc)
103
+ @documents << doc
104
+ end
105
+
106
+ def count
107
+ @documents.length
108
+ end
109
+
110
+ def get_by_slug(slug_value)
111
+ @documents.find { |d| d.slug == slug_value }
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hammer
4
+ module Content
5
+ class ExpressionEvaluator
6
+ def initialize(context:)
7
+ @context = context
8
+ end
9
+
10
+ def evaluate(expr)
11
+ trimmed = expr.strip
12
+ return evaluate_null_coalescing(trimmed) if trimmed.include?(" ?? ")
13
+ return evaluate_method_call(trimmed) if trimmed.include?("(")
14
+
15
+ @context.resolve(trimmed)
16
+ end
17
+
18
+ def evaluate_null_coalescing(expr)
19
+ left, right = expr.split(" ?? ", 2)
20
+ val = evaluate(left.strip)
21
+ if val.nil? || (val.is_a?(String) && val.empty?) || (val.is_a?(Array) && val.empty?)
22
+ return evaluate(right.strip)
23
+ end
24
+
25
+ val
26
+ end
27
+
28
+ def evaluate_method_call(expr)
29
+ open = expr.index("(")
30
+ method_name = expr[0...open].strip
31
+ args_str = expr[open + 1..]
32
+ close = find_matching_paren(args_str)
33
+ args = parse_arguments(args_str[0...close])
34
+
35
+ evaluated = args.map { |a| evaluate(a) }
36
+ if method_name.start_with?("helpers.")
37
+ Helpers.call(method_name.split(".", 2)[1], evaluated)
38
+ else
39
+ Helpers.call(method_name, evaluated)
40
+ end
41
+ end
42
+
43
+ def find_matching_paren(s)
44
+ depth = 1
45
+ i = 0
46
+ while i < s.length
47
+ case s[i]
48
+ when "(" then depth += 1
49
+ when ")"
50
+ depth -= 1
51
+ return i if depth.zero?
52
+ end
53
+ i += 1
54
+ end
55
+ s.length - 1
56
+ end
57
+
58
+ def parse_arguments(s)
59
+ s = s.strip
60
+ return [] if s.empty?
61
+
62
+ args = []
63
+ current = +""
64
+ in_quotes = false
65
+ quote = nil
66
+ depth = 0
67
+
68
+ s.each_char do |ch|
69
+ if !in_quotes && (ch == '"' || ch == "'")
70
+ in_quotes = true
71
+ quote = ch
72
+ current << ch
73
+ elsif in_quotes && ch == quote
74
+ in_quotes = false
75
+ quote = nil
76
+ current << ch
77
+ elsif !in_quotes && ch == "("
78
+ depth += 1
79
+ current << ch
80
+ elsif !in_quotes && ch == ")"
81
+ depth -= 1
82
+ current << ch
83
+ elsif !in_quotes && depth.zero? && ch == ","
84
+ args << current.strip
85
+ current = +""
86
+ else
87
+ current << ch
88
+ end
89
+ end
90
+ args << current.strip unless current.strip.empty?
91
+ args
92
+ end
93
+
94
+ def stringify(value)
95
+ case value
96
+ when nil then ""
97
+ when String then value
98
+ when TrueClass, FalseClass then value.to_s
99
+ when Numeric then value.to_s
100
+ when Time, Date then value.to_s
101
+ when Document then value.slug
102
+ else value.to_s
103
+ end
104
+ end
105
+
106
+ def escape_html(value)
107
+ CGI.escapeHTML(stringify(value))
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ require "cgi"
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hammer
4
+ module Content
5
+ module Helpers
6
+ module_function
7
+
8
+ def registry
9
+ @registry ||= build_registry
10
+ end
11
+
12
+ def build_registry
13
+ {
14
+ "formatDate" => method(:format_date),
15
+ "slugify" => ->(args) { Document.slugify(args[0].to_s) },
16
+ "markdown" => ->(args) { args[0].to_s },
17
+ "relativePath" => method(:relative_path),
18
+ "join" => method(:join_helper),
19
+ "default" => method(:default_helper)
20
+ }
21
+ end
22
+
23
+ def call(name, args)
24
+ fn = registry[name]
25
+ raise BuildError, "Helper not found: #{name}" unless fn
26
+
27
+ fn.call(args)
28
+ end
29
+
30
+ def format_date(args)
31
+ date_val = args[0]
32
+ format = args[1].to_s
33
+ format = "medium" if format.empty? || format == "null"
34
+
35
+ time = case date_val
36
+ when Time, Date then date_val.to_time
37
+ when String then Document.parse_date(date_val)
38
+ else nil
39
+ end
40
+ raise BuildError, "Invalid date" unless time
41
+
42
+ case format.downcase
43
+ when "short" then time.strftime("%m/%d/%y")
44
+ when "medium" then time.strftime("%b %d, %Y")
45
+ when "long" then time.strftime("%B %d, %Y")
46
+ when "full" then time.strftime("%A, %B %d, %Y")
47
+ when "iso", "iso8601" then time.utc.iso8601
48
+ else time.strftime(format)
49
+ end
50
+ end
51
+
52
+ def relative_path(args)
53
+ from = args[0].to_s
54
+ to = args[1].to_s
55
+ from_dir = File.dirname(from).split("/")
56
+ to_parts = to.split("/")
57
+ common = 0
58
+ common += 1 while common < from_dir.length && common < to_parts.length && from_dir[common] == to_parts[common]
59
+ up = from_dir.length - common
60
+ remaining = to_parts[common..]
61
+ ([".."] * up + remaining).join("/")
62
+ end
63
+
64
+ def join_helper(args)
65
+ arr = array_elements(args[0]) || []
66
+ sep = args[1].to_s
67
+ sep = ", " if sep.empty? || sep == "null"
68
+ arr.map(&:to_s).join(sep)
69
+ end
70
+
71
+ def default_helper(args)
72
+ val = args[0]
73
+ fallback = args[1]
74
+ return fallback if val.nil? || (val.is_a?(String) && val.empty?)
75
+
76
+ val
77
+ end
78
+
79
+ def array_elements(value)
80
+ return value if value.is_a?(Array)
81
+ return nil if value.nil?
82
+
83
+ if value.is_a?(String) && value.start_with?("[")
84
+ begin
85
+ return JSON.parse(value)
86
+ rescue JSON::ParserError
87
+ nil
88
+ end
89
+ end
90
+ nil
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ require "json"
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Hammer
7
+ module Content
8
+ class PageGenerator
9
+ def self.pre_process_collection_loops(content, collections:, source_directory:, build_mode:, delegate: nil)
10
+ return content unless content.include?("@loop") && content.include?("collections.")
11
+
12
+ generator = new(
13
+ source_directory: source_directory,
14
+ build_directory: source_directory,
15
+ config: Config.new(collections: {}),
16
+ collections: collections,
17
+ build_mode: build_mode,
18
+ delegate: delegate
19
+ )
20
+ site = { "title" => "My Site" }
21
+ context = TemplateContext.new(site: site, collections: collections, helpers: Helpers.registry)
22
+ evaluator = ExpressionEvaluator.new(context: context)
23
+ processed, loop_vars = generator.convert_loop_expressions(content, evaluator)
24
+ processed = generator.convert_loop_expressions_to_at_syntax(processed)
25
+ unless loop_vars.empty?
26
+ processed = loop_vars.join("\n") + "\n" + processed
27
+ end
28
+ processed
29
+ end
30
+
31
+ def initialize(source_directory:, build_directory:, config:, collections:, build_mode:, delegate: nil)
32
+ @source_directory = source_directory
33
+ @build_directory = build_directory
34
+ @config = config
35
+ @collections = collections
36
+ @build_mode = build_mode
37
+ @delegate = delegate
38
+ end
39
+
40
+ def generate_all_pages!
41
+ @config.collections.each_key do |name|
42
+ generate_detail_pages(name)
43
+ end
44
+ end
45
+
46
+ def generate_detail_pages(collection_name)
47
+ collection = @collections[collection_name]
48
+ collection_config = @config.get_collection(collection_name)
49
+ return unless collection && collection_config&.template && collection_config.output_path_pattern
50
+
51
+ template_path = File.join(@source_directory, collection_config.template)
52
+ collection.documents.each do |doc|
53
+ output_path = resolve_output_path(doc, collection_config)
54
+ next if output_path.empty?
55
+
56
+ generate_page(template_path, doc, output_path)
57
+ end
58
+ end
59
+
60
+ def resolve_output_path(doc, collection_config)
61
+ pattern = collection_config.output_path_pattern
62
+ slug_field = collection_config.slug&.dig("field") || "slug"
63
+ slug = resolve_slug(doc, collection_config.slug)
64
+ interpolate_output_path(pattern, slug, slug_field)
65
+ end
66
+
67
+ def resolve_slug(doc, slug_config)
68
+ if slug_config
69
+ field = slug_config["field"] || slug_config[:field]
70
+ fallback = slug_config["fallback"] || slug_config[:fallback]
71
+ if (v = doc.metadata[field]).is_a?(String) && !v.empty?
72
+ return Document.slugify(v)
73
+ end
74
+ if fallback && (v = doc.metadata[fallback]).is_a?(String) && !v.empty?
75
+ return Document.slugify(v)
76
+ end
77
+ end
78
+ doc.slug
79
+ end
80
+
81
+ def interpolate_output_path(pattern, slug, slug_field)
82
+ result = pattern.dup
83
+ result.gsub!(/\{\{\s*#{Regexp.escape(slug_field)}\s*\}\}/, slug)
84
+ result.gsub!(/\{\{\s*slug\s*\}\}/, slug)
85
+ result.gsub!(/\{#{Regexp.escape(slug_field)}\}/, slug)
86
+ result.gsub!("{slug}", slug)
87
+ result
88
+ end
89
+
90
+ def generate_page(template_path, doc, output_path)
91
+ template = File.read(template_path)
92
+ site = { "title" => "My Site" }
93
+ context = TemplateContext.new(
94
+ site: site,
95
+ collections: @collections,
96
+ doc: doc,
97
+ helpers: Helpers.registry
98
+ )
99
+ evaluator = ExpressionEvaluator.new(context: context)
100
+
101
+ content, cond_vars = convert_conditional_expressions(template, evaluator)
102
+ content, loop_vars = convert_loop_expressions(content, evaluator)
103
+ content = convert_closing_tags(content)
104
+ content = convert_loop_expressions_to_at_syntax(content)
105
+ all_vars = cond_vars + loop_vars
106
+ content = all_vars.join("\n") + "\n" + content unless all_vars.empty?
107
+ content = evaluate_variable_declarations(content, evaluator)
108
+ content = process_expressions_outside_blocks(content, evaluator)
109
+
110
+ tag_parser = TagParser.new(
111
+ source_directory: @source_directory,
112
+ build_mode: @build_mode,
113
+ delegate: @delegate,
114
+ content_collections: @collections,
115
+ content_config: @config
116
+ )
117
+ final = tag_parser.process_file_content(content, "_temp_page.html", output_path)
118
+ final = process_expressions(final, evaluator)
119
+ final = cleanup_remaining_hammer_tags(final)
120
+
121
+ dest = File.join(@build_directory, output_path)
122
+ FileUtils.mkdir_p(File.dirname(dest))
123
+ final += "\n" unless final.end_with?("\n")
124
+ File.write(dest, final)
125
+ @delegate&.processed_file(output_path, dest)
126
+ end
127
+
128
+ def convert_conditional_expressions(content, evaluator)
129
+ vars = []
130
+ result = content.dup
131
+ pattern = /<!--\s*@(if|unless)\s+(.+?)\s*-->/m
132
+ matches = []
133
+ result.scan(pattern) { matches << Regexp.last_match(0) }
134
+
135
+ matches.reverse_each do |original|
136
+ m = original.match(pattern)
137
+ next unless m
138
+
139
+ type = m[1]
140
+ condition = m[2].strip
141
+ next if condition.start_with?("_cond_")
142
+
143
+ var_name = "_cond_#{SecureRandom.hex(8)}"
144
+ met = evaluate_condition(condition, evaluator)
145
+ met = !met if type == "unless"
146
+ vars << "<!-- @var #{var_name} = \"#{met ? 'true' : 'false'}\" -->"
147
+ result.sub!(original, "<!-- @#{type} #{var_name} -->")
148
+ end
149
+
150
+ [result, vars]
151
+ end
152
+
153
+ def evaluate_condition(condition, evaluator)
154
+ if condition.include?("&&") || condition.include?("||")
155
+ and_parts = condition.split("&&")
156
+ and_parts.all? do |and_part|
157
+ if and_part.include?("||")
158
+ and_part.split("||").any? { |p| evaluate_condition_part(p.strip, evaluator) }
159
+ else
160
+ evaluate_condition_part(and_part.strip, evaluator)
161
+ end
162
+ end
163
+ else
164
+ evaluate_condition_part(condition, evaluator)
165
+ end
166
+ end
167
+
168
+ def evaluate_condition_part(expr, evaluator)
169
+ if expr.match?(/[<>!=]=?/)
170
+ return numeric_compare(expr, evaluator)
171
+ end
172
+
173
+ val = evaluator.evaluate(expr)
174
+ truthy?(val)
175
+ end
176
+
177
+ def numeric_compare(expr, evaluator)
178
+ ops = [">=", "<=", "!=", "==", ">", "<"]
179
+ op = ops.find { |o| expr.include?(o) }
180
+ return truthy?(evaluator.evaluate(expr)) unless op
181
+
182
+ left, right = expr.split(op, 2).map(&:strip)
183
+ l = numeric_value(left, evaluator)
184
+ r = numeric_value(right, evaluator)
185
+ return truthy?(evaluator.evaluate(expr)) if l.nil? || r.nil?
186
+
187
+ case op
188
+ when ">" then l > r
189
+ when "<" then l < r
190
+ when ">=" then l >= r
191
+ when "<=" then l <= r
192
+ when "==" then l == r
193
+ when "!=" then l != r
194
+ else false
195
+ end
196
+ end
197
+
198
+ def numeric_value(expr, evaluator)
199
+ return Integer(expr) if expr.match?(/\A-?\d+\z/)
200
+
201
+ val = evaluator.evaluate(expr)
202
+ if val.is_a?(Array)
203
+ val.length
204
+ elsif val.is_a?(Integer)
205
+ val
206
+ elsif val.is_a?(Numeric)
207
+ val.to_i
208
+ elsif val.is_a?(String) && val.match?(/\A-?\d+\z/)
209
+ Integer(val)
210
+ end
211
+ end
212
+
213
+ def truthy?(value)
214
+ case value
215
+ when nil then false
216
+ when false then false
217
+ when Array then !value.empty?
218
+ when String then !value.empty?
219
+ else true
220
+ end
221
+ end
222
+
223
+ def convert_loop_expressions(content, evaluator)
224
+ vars = []
225
+ result = content.dup
226
+ pattern = /<!--\s*@loop\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+in\s+(.+?)\s*-->/m
227
+ matches = []
228
+ result.scan(pattern) { matches << Regexp.last_match(0) }
229
+
230
+ matches.reverse_each do |original|
231
+ m = original.match(pattern)
232
+ next unless m
233
+
234
+ item_name = m[1]
235
+ collection_expr = m[2].strip
236
+ next if collection_expr.start_with?("_loop_")
237
+
238
+ collection_value = evaluator.evaluate(collection_expr)
239
+ items = collection_to_json_items(collection_value)
240
+ json_array = JSON.generate(items)
241
+ escaped = json_array.gsub("\\", "\\\\").gsub('"', '\\"').gsub("\n", "\\n")
242
+ var_name = "_loop_#{item_name}_#{SecureRandom.hex(8)}"
243
+ vars << "<!-- @var #{var_name} = \"#{escaped}\" -->"
244
+ result.sub!(original, "<!-- @loop #{item_name} in #{var_name} -->")
245
+ end
246
+
247
+ [result, vars]
248
+ end
249
+
250
+ def collection_to_json_items(value)
251
+ value = parse_json_collection(value) if value.is_a?(String)
252
+
253
+ case value
254
+ when Collection
255
+ value.documents.map(&:to_h)
256
+ when Array
257
+ value.map do |item|
258
+ case item
259
+ when Document then item.to_h
260
+ when String then { "value" => item }
261
+ when Hash then item
262
+ else { "value" => item.to_s }
263
+ end
264
+ end
265
+ when Document
266
+ [value.to_h]
267
+ else
268
+ [{ "value" => value.to_s }]
269
+ end
270
+ end
271
+
272
+ def parse_json_collection(value)
273
+ trimmed = value.strip
274
+ return value unless trimmed.start_with?("[") || trimmed.start_with?("{")
275
+
276
+ JSON.parse(trimmed)
277
+ rescue JSON::ParserError
278
+ value
279
+ end
280
+
281
+ def convert_closing_tags(content)
282
+ content
283
+ end
284
+
285
+ def convert_loop_expressions_to_at_syntax(content)
286
+ content.gsub(/<!--\s*@loop\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+(in\s+[^>]+?)\s*-->(.*?)<!--\s*@endloop\s*-->/m) do
287
+ item = Regexp.last_match(1)
288
+ loop_in = Regexp.last_match(2)
289
+ body = Regexp.last_match(3)
290
+ converted = body.gsub(/<!--\s*\$!?#{Regexp.escape(item)}(\.[a-zA-Z_][a-zA-Z0-9_.-]*)?\s*-->/) do
291
+ prop = Regexp.last_match(1) || ".value"
292
+ "@#{item}#{prop}"
293
+ end
294
+ "<!-- @loop #{item} #{loop_in} -->#{converted}<!-- @endloop -->"
295
+ end
296
+ end
297
+
298
+ def evaluate_variable_declarations(content, evaluator)
299
+ content.gsub(/<!--\s*@var\s+([^=]+?)\s*=\s*(.*?)\s*-->/m) do
300
+ name = Regexp.last_match(1).strip
301
+ value = Regexp.last_match(2).strip
302
+ if value.include?("$") || value.match?(/\bdoc\./)
303
+ evaluated = value.gsub(/\$([a-zA-Z_][a-zA-Z0-9_.]*)/) do
304
+ evaluator.stringify(evaluator.evaluate(Regexp.last_match(1)))
305
+ end
306
+ "<!-- @var #{name} = #{evaluated} -->"
307
+ else
308
+ Regexp.last_match(0)
309
+ end
310
+ end
311
+ end
312
+
313
+ def process_expressions_outside_blocks(content, evaluator)
314
+ protected = []
315
+ content.scan(/<!--\s*@(if|unless|loop)\s+[^>]+?-->(.*?)<!--\s*@(end|endloop|endif|endunless)\s*-->/m) do
316
+ # mark protected ranges - simplified: process all for template pages
317
+ end
318
+ process_expressions(content, evaluator)
319
+ end
320
+
321
+ def process_expressions(content, evaluator)
322
+ result = content.dup
323
+ result.gsub!(/<!--\s*\$!([^>]+?)\s*-->/) do
324
+ evaluator.stringify(evaluator.evaluate(Regexp.last_match(1).strip))
325
+ end
326
+ result.gsub!(/<!--\s*\$([^>!][^>]*?)\s*-->/) do
327
+ evaluator.escape_html(evaluator.evaluate(Regexp.last_match(1).strip))
328
+ end
329
+ result
330
+ end
331
+
332
+ def cleanup_remaining_hammer_tags(content)
333
+ patterns = [
334
+ /<!--\s*@if\s+[^>]+?-->/,
335
+ /<!--\s*@unless\s+[^>]+?-->/,
336
+ /<!--\s*@loop\s+[^>]+?-->/,
337
+ /<!--\s*@(endif|endunless|endloop|end|else)\s*-->/
338
+ ]
339
+ result = content.dup
340
+ patterns.each { |pat| result.gsub!(pat, "") }
341
+ result.gsub!(/(\S)\s*,\s*\n\s*(?=<\/)/, '\1')
342
+
343
+ cleaned_lines = []
344
+ last_was_empty = false
345
+ result.each_line(chomp: true) do |line|
346
+ if line.strip.empty?
347
+ unless last_was_empty
348
+ cleaned_lines << ""
349
+ last_was_empty = true
350
+ end
351
+ else
352
+ cleaned_lines << line.rstrip
353
+ last_was_empty = false
354
+ end
355
+ end
356
+ cleaned_lines.join("\n")
357
+ end
358
+ end
359
+ end
360
+ end
361
+
362
+ require "fileutils"