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,463 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hammer
4
+ module Content
5
+ class Parser
6
+ def initialize(source_directory:)
7
+ @source_directory = source_directory
8
+ end
9
+
10
+ def parse_markdown_file(path)
11
+ content = File.read(path)
12
+ metadata, body = extract_front_matter(content)
13
+ html_body = parse_markdown_body(body)
14
+ collection = extract_collection(path)
15
+ Document.new(
16
+ file_path: path,
17
+ metadata: metadata,
18
+ body: html_body,
19
+ content_type: :markdown,
20
+ collection: collection
21
+ )
22
+ end
23
+
24
+ def parse_yaml_file(path)
25
+ metadata = parse_simple_yaml(File.read(path))
26
+ Document.new(
27
+ file_path: path,
28
+ metadata: metadata,
29
+ body: "",
30
+ content_type: :yaml,
31
+ collection: extract_collection(path)
32
+ )
33
+ end
34
+
35
+ def extract_collection(path)
36
+ rel = path.delete_prefix("#{@source_directory}/")
37
+ parts = rel.split("/")
38
+ parts[1] if parts[0] == "content" && parts.length > 2
39
+ end
40
+
41
+ def extract_front_matter(content)
42
+ return [{}, content] unless content.start_with?("---\n")
43
+
44
+ rest = content[4..]
45
+ return [{}, content] unless (end_idx = rest.index("\n---"))
46
+
47
+ yaml_text = rest[0...end_idx]
48
+ body = rest[(end_idx + 4)..].sub(/\A\n?/, "")
49
+ [parse_simple_yaml(yaml_text), body]
50
+ end
51
+
52
+ def parse_simple_yaml(text)
53
+ metadata = {}
54
+ current_key = nil
55
+ text.each_line do |line|
56
+ stripped = line.strip
57
+ next if stripped.empty?
58
+
59
+ if (m = stripped.match(/\A([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)\z/))
60
+ key = m[1]
61
+ value = m[2].strip
62
+ metadata[key] = parse_yaml_value(value)
63
+ current_key = key
64
+ elsif stripped.start_with?("- ") && current_key
65
+ metadata[current_key] ||= []
66
+ metadata[current_key] << parse_yaml_value(stripped[2..].strip)
67
+ end
68
+ end
69
+ metadata
70
+ end
71
+
72
+ def parse_yaml_value(value)
73
+ return true if value == "true"
74
+ return false if value == "false"
75
+ return nil if value == "null" || value.empty?
76
+ return Integer(value) if value.match?(/\A-?\d+\z/)
77
+ return Float(value) if value.match?(/\A-?\d+\.\d+\z/)
78
+ if value.start_with?("[") && value.end_with?("]")
79
+ inner = value[1..-2]
80
+ return [] if inner.strip.empty?
81
+
82
+ return inner.split(",").map { |v| parse_yaml_value(v.strip.delete_prefix('"').delete_suffix('"')) }
83
+ end
84
+
85
+ value.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'")
86
+ end
87
+
88
+ def parse_markdown_body(markdown)
89
+ html = markdown.dup
90
+
91
+ html.gsub!(/```(\w+)?\s*\n([\s\S]*?)```/) do
92
+ lang = Regexp.last_match(1).to_s
93
+ code = Regexp.last_match(2)
94
+ code = code.gsub(/\A\n+/, "").gsub(/\n+\z/, "")
95
+ escaped = CGI.escapeHTML(code)
96
+ cls = lang.empty? ? "" : " class=\"language-#{lang}\""
97
+ "<pre><code#{cls}>#{escaped}</code></pre>"
98
+ end
99
+
100
+ {
101
+ /^### (.*?)$/ => "<h3>\\1</h3>",
102
+ /^## (.*?)$/ => "<h2>\\1</h2>",
103
+ /^# (.*?)$/ => "<h1>\\1</h1>"
104
+ }.each { |pat, repl| html.gsub!(pat, repl) }
105
+
106
+ placeholders = []
107
+ html.gsub!(%r{</?[a-zA-Z][^>]*>}) do |tag|
108
+ token = "HAMMERHTMLTAGTOKEN#{placeholders.length}ZZEND"
109
+ placeholders << [token, tag]
110
+ token
111
+ end
112
+
113
+ html.gsub!(/\*\*(.+?)\*\*/, '<strong>\1</strong>')
114
+ html.gsub!(/__(.+?)__/, '<strong>\1</strong>')
115
+ html.gsub!(/\*(.+?)\*/, '<em>\1</em>')
116
+ html.gsub!(/_(.+?)_/, '<em>\1</em>')
117
+
118
+ placeholders.each { |token, tag| html.gsub!(token, tag) }
119
+
120
+ html.gsub!(/\[([^\]]+)\]\(([^)]+)\)/, '<a href="\2">\1</a>')
121
+ html.gsub!(/<(https?:\/\/[^>]+)>/, '<a href="\1">\1</a>')
122
+ html.gsub!(/!\[([^\]]*)\]\(([^)]+)\)/, '<img src="\2" alt="\1">')
123
+ html.gsub!(/`([^`]+)`/, '<code>\1</code>')
124
+
125
+ process_block_level_elements(html).then { |processed| wrap_paragraphs(processed) }
126
+ end
127
+
128
+ BLOCK_TAG_SET = %w[
129
+ div pre code section article aside header footer nav main figure blockquote
130
+ table thead tbody tfoot tr ul ol dl fieldset form style script
131
+ ].freeze
132
+
133
+ WRAP_BLOCK_TAG_SET = (BLOCK_TAG_SET + %w[h1 h2 h3 h4 h5 h6 hr]).freeze
134
+
135
+ TAG_PATTERN = %r{<(/?)([a-zA-Z][a-zA-Z0-9]*)(?:\s[^>]*)?(/?)\s*>}i
136
+
137
+ def process_block_level_elements(html)
138
+ processed_lines = []
139
+ state = {
140
+ in_unordered_list: false,
141
+ in_ordered_list: false,
142
+ in_blockquote: false,
143
+ in_table: false
144
+ }
145
+ blockquote_content = []
146
+ table_rows = []
147
+ html_block_depth = 0
148
+ standalone_indented_block = []
149
+
150
+ flush_standalone = lambda do
151
+ next if standalone_indented_block.empty?
152
+
153
+ block_content = standalone_indented_block.join("\n")
154
+ processed_lines << %(<div class="indent">#{block_content}</div>)
155
+ standalone_indented_block.clear
156
+ end
157
+
158
+ close_blocks = lambda do
159
+ if state[:in_unordered_list]
160
+ processed_lines << "</ul>"
161
+ state[:in_unordered_list] = false
162
+ end
163
+ if state[:in_ordered_list]
164
+ processed_lines << "</ol>"
165
+ state[:in_ordered_list] = false
166
+ end
167
+ if state[:in_blockquote]
168
+ append_blockquote(processed_lines, blockquote_content)
169
+ state[:in_blockquote] = false
170
+ end
171
+ if state[:in_table]
172
+ append_table(processed_lines, table_rows)
173
+ state[:in_table] = false
174
+ end
175
+ end
176
+
177
+ close_other_lists = lambda do |keep|
178
+ if keep != :unordered && state[:in_unordered_list]
179
+ processed_lines << "</ul>"
180
+ state[:in_unordered_list] = false
181
+ end
182
+ if keep != :ordered && state[:in_ordered_list]
183
+ processed_lines << "</ol>"
184
+ state[:in_ordered_list] = false
185
+ end
186
+ if state[:in_blockquote]
187
+ append_blockquote(processed_lines, blockquote_content)
188
+ state[:in_blockquote] = false
189
+ end
190
+ if state[:in_table]
191
+ append_table(processed_lines, table_rows)
192
+ state[:in_table] = false
193
+ end
194
+ end
195
+
196
+ html.each_line(chomp: true) do |line|
197
+ trimmed = line.strip
198
+
199
+ depth_at_start = html_block_depth
200
+ depth_after = html_block_depth
201
+ line.scan(TAG_PATTERN) do |closing, tag_name, self_closing|
202
+ tag = tag_name.downcase
203
+ next unless BLOCK_TAG_SET.include?(tag)
204
+
205
+ if self_closing == "/"
206
+ next
207
+ elsif closing == "/"
208
+ depth_after = [0, depth_after - 1].max
209
+ else
210
+ depth_after += 1
211
+ end
212
+ end
213
+ html_block_depth = depth_after
214
+ in_protected_html = depth_at_start.positive? || trimmed.start_with?("<")
215
+
216
+ standalone_indented = !trimmed.empty? &&
217
+ !(state[:in_unordered_list] || state[:in_ordered_list]) &&
218
+ !in_protected_html &&
219
+ (line.start_with?(" ") || line.start_with?("\t"))
220
+
221
+ if standalone_indented
222
+ content = line.dup
223
+ content = content[1..] if content.start_with?("\t")
224
+ content = content[2..] if content.start_with?(" ")
225
+ standalone_indented_block << content.strip
226
+ next
227
+ else
228
+ flush_standalone.call
229
+ end
230
+
231
+ if %w[--- *** ___].include?(trimmed)
232
+ close_blocks.call
233
+ processed_lines << "<hr>"
234
+ next
235
+ end
236
+
237
+ if trimmed.start_with?("|") && trimmed.end_with?("|") && trimmed.length > 2
238
+ without_pipes = trimmed.delete("|").strip
239
+ is_separator = without_pipes.include?("-") &&
240
+ without_pipes.chars.all? { |c| ["-", ":", " "].include?(c) }
241
+ if is_separator
242
+ state[:in_table] = true if table_rows.any?
243
+ next
244
+ end
245
+
246
+ unless state[:in_table]
247
+ close_blocks.call
248
+ state[:in_table] = true
249
+ table_rows = []
250
+ end
251
+ table_rows << trimmed
252
+ next
253
+ end
254
+
255
+ if trimmed.start_with?(">")
256
+ close_blocks.call
257
+ unless state[:in_blockquote]
258
+ state[:in_blockquote] = true
259
+ blockquote_content = []
260
+ end
261
+ blockquote_content << trimmed[1..].strip
262
+ next
263
+ end
264
+
265
+ if (m = line.match(/\A\s*[-*]\s+(.+)\z/))
266
+ close_other_lists.call(:unordered)
267
+ unless state[:in_unordered_list]
268
+ processed_lines << "<ul>"
269
+ state[:in_unordered_list] = true
270
+ end
271
+ processed_lines << "<li>#{m[1].strip}</li>"
272
+ next
273
+ end
274
+
275
+ if (m = line.match(/\A\s*\d+\.\s+(.+)\z/))
276
+ close_other_lists.call(:ordered)
277
+ unless state[:in_ordered_list]
278
+ processed_lines << "<ol>"
279
+ state[:in_ordered_list] = true
280
+ end
281
+ processed_lines << "<li>#{m[1].strip}</li>"
282
+ next
283
+ end
284
+
285
+ if trimmed.empty?
286
+ if state[:in_table]
287
+ append_table(processed_lines, table_rows)
288
+ state[:in_table] = false
289
+ elsif state[:in_blockquote]
290
+ blockquote_content << ""
291
+ else
292
+ processed_lines << ""
293
+ end
294
+ next
295
+ end
296
+
297
+ if (state[:in_unordered_list] || state[:in_ordered_list]) &&
298
+ (line.start_with?(" ") || line.start_with?("\t"))
299
+ last_index = processed_lines.rindex { |l| l.start_with?("<li>") }
300
+ if last_index
301
+ continuation = trimmed
302
+ last_item = processed_lines[last_index]
303
+ if last_item.end_with?("</li>")
304
+ item_content = last_item[0...-5]
305
+ processed_lines[last_index] = "#{item_content} #{continuation}</li>"
306
+ end
307
+ next
308
+ end
309
+ end
310
+
311
+ close_blocks.call
312
+ processed_lines << line
313
+ end
314
+
315
+ flush_standalone.call
316
+ close_blocks.call
317
+ processed_lines.join("\n")
318
+ end
319
+
320
+ def append_blockquote(processed_lines, blockquote_content)
321
+ blockquote_html = []
322
+ current_paragraph = []
323
+
324
+ blockquote_content.each do |content|
325
+ if content.empty?
326
+ unless current_paragraph.empty?
327
+ blockquote_html << "<p>#{current_paragraph.join(' ')}</p>"
328
+ current_paragraph.clear
329
+ end
330
+ else
331
+ current_paragraph << content
332
+ end
333
+ end
334
+
335
+ blockquote_html << "<p>#{current_paragraph.join(' ')}</p>" unless current_paragraph.empty?
336
+ blockquote_html << "<p></p>" if blockquote_html.empty?
337
+ processed_lines << "<blockquote>#{blockquote_html.join("\n")}</blockquote>"
338
+ blockquote_content.clear
339
+ end
340
+
341
+ def append_table(processed_lines, table_rows)
342
+ return if table_rows.empty?
343
+
344
+ parsed_rows = table_rows.filter_map do |row|
345
+ cells = row.split("|").map(&:strip)
346
+ cells.shift if cells.first&.empty?
347
+ cells.pop if cells.last&.empty?
348
+ cells.empty? ? nil : cells
349
+ end
350
+ return if parsed_rows.empty?
351
+
352
+ table_html = ["<table>", "<thead>", "<tr>"]
353
+ parsed_rows.first.each { |cell| table_html << "<th>#{cell}</th>" }
354
+ table_html.concat(["</tr>", "</thead>"])
355
+
356
+ if parsed_rows.length > 1
357
+ table_html << "<tbody>"
358
+ parsed_rows[1..].each do |row|
359
+ table_html << "<tr>"
360
+ row.each { |cell| table_html << "<td>#{cell}</td>" }
361
+ table_html << "</tr>"
362
+ end
363
+ table_html << "</tbody>"
364
+ end
365
+
366
+ table_html << "</table>"
367
+ processed_lines << table_html.join("\n")
368
+ table_rows.clear
369
+ end
370
+
371
+ def wrap_paragraphs(html)
372
+ wrapped = []
373
+ current_paragraph = []
374
+ html_block_depth = 0
375
+ in_pre_block = false
376
+
377
+ html.each_line(chomp: true) do |line|
378
+ trimmed = in_pre_block ? line : line.strip
379
+ depth_at_start = html_block_depth
380
+ depth_after = html_block_depth
381
+ tag_matches = trimmed.scan(TAG_PATTERN)
382
+
383
+ tag_matches.each do |closing, tag_name, _self_closing|
384
+ tag = tag_name.downcase
385
+ if tag == "pre"
386
+ in_pre_block = closing != "/"
387
+ break
388
+ end
389
+ end
390
+
391
+ tag_matches.each do |closing, tag_name, self_closing|
392
+ tag = tag_name.downcase
393
+ next unless WRAP_BLOCK_TAG_SET.include?(tag)
394
+
395
+ if self_closing == "/"
396
+ next
397
+ elsif closing == "/"
398
+ depth_after = [0, depth_after - 1].max
399
+ else
400
+ depth_after += 1
401
+ end
402
+ end
403
+ html_block_depth = depth_after
404
+
405
+ is_inside_block = depth_at_start.positive? || depth_after.positive?
406
+ first_tag = tag_matches.first&.then { |m| m[1].downcase }
407
+ is_block_element = trimmed.start_with?("<") && first_tag && WRAP_BLOCK_TAG_SET.include?(first_tag)
408
+
409
+ if in_pre_block
410
+ unless current_paragraph.empty?
411
+ wrapped << current_paragraph.join(" ")
412
+ current_paragraph.clear
413
+ end
414
+ wrapped << line
415
+ elsif trimmed.empty?
416
+ unless current_paragraph.empty? && !is_inside_block
417
+ if !current_paragraph.empty? && !is_inside_block
418
+ wrapped << "<p>#{current_paragraph.join(' ')}</p>"
419
+ current_paragraph.clear
420
+ end
421
+ end
422
+ elsif is_block_element
423
+ unless current_paragraph.empty? && !is_inside_block
424
+ if !current_paragraph.empty? && !is_inside_block
425
+ wrapped << "<p>#{current_paragraph.join(' ')}</p>"
426
+ current_paragraph.clear
427
+ end
428
+ end
429
+ wrapped << trimmed
430
+ elsif trimmed.start_with?("<")
431
+ if is_inside_block
432
+ unless current_paragraph.empty?
433
+ wrapped << current_paragraph.join(" ")
434
+ current_paragraph.clear
435
+ end
436
+ wrapped << trimmed
437
+ else
438
+ current_paragraph << trimmed
439
+ end
440
+ elsif is_inside_block
441
+ unless current_paragraph.empty?
442
+ wrapped << current_paragraph.join(" ")
443
+ current_paragraph.clear
444
+ end
445
+ wrapped << trimmed
446
+ else
447
+ current_paragraph << trimmed
448
+ end
449
+ end
450
+
451
+ if !current_paragraph.empty? && html_block_depth.zero?
452
+ wrapped << "<p>#{current_paragraph.join(' ')}</p>"
453
+ elsif !current_paragraph.empty?
454
+ wrapped << current_paragraph.join(" ")
455
+ end
456
+
457
+ wrapped.join("\n")
458
+ end
459
+ end
460
+ end
461
+ end
462
+
463
+ require "cgi"
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hammer
4
+ module Content
5
+ class RelationResolver
6
+ def initialize(collections:, config:)
7
+ @collections = collections
8
+ @config = config
9
+ end
10
+
11
+ def resolve_all!
12
+ @config.collections.each do |name, collection_config|
13
+ populate_relations(name, collection_config)
14
+ end
15
+ end
16
+
17
+ def populate_relations(collection_name, collection_config)
18
+ collection = @collections[collection_name]
19
+ return unless collection
20
+
21
+ fields = collection_config.fields || {}
22
+ collection.documents.each do |doc|
23
+ fields.each do |field_name, field|
24
+ next unless field.type == "relation"
25
+ next unless doc.metadata.key?(field_name)
26
+
27
+ raw = doc.metadata[field_name]
28
+ next unless raw.is_a?(String)
29
+
30
+ resolved = resolve_relation(raw, field, collection_name)
31
+ doc.metadata[field_name] = resolved if resolved
32
+ end
33
+ end
34
+ end
35
+
36
+ def resolve_relation(relation_value, field, _from_collection)
37
+ target_name = field.collection
38
+ return nil unless target_name
39
+
40
+ target = @collections[target_name]
41
+ return nil unless target
42
+
43
+ slug_value = relation_value.include?("/") ? relation_value.split("/").last : relation_value
44
+ value_field = field.value_field || "slug"
45
+
46
+ if value_field == "slug"
47
+ target.get_by_slug(slug_value)
48
+ else
49
+ target.documents.find { |d| d.metadata[value_field].to_s == slug_value }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hammer
4
+ module Content
5
+ class TemplateContext
6
+ attr_reader :site, :collections, :doc, :helpers, :variables
7
+
8
+ def initialize(site: {}, collections: {}, doc: nil, helpers: {}, variables: {})
9
+ @site = site
10
+ @collections = collections
11
+ @doc = doc
12
+ @helpers = helpers
13
+ @variables = variables
14
+ end
15
+
16
+ def resolve(path)
17
+ parts = path.split(".")
18
+ root = parts.first
19
+ rest = parts[1..]
20
+
21
+ case root
22
+ when "doc" then resolve_doc(rest)
23
+ when "collections" then resolve_collections(rest)
24
+ when "site" then dig(@site, rest)
25
+ when "helpers" then rest.length == 1 ? @helpers[rest[0]] : nil
26
+ else
27
+ dig(@variables[root], rest[0..]) || nil
28
+ end
29
+ end
30
+
31
+ def resolve_doc(parts)
32
+ return @doc if parts.empty?
33
+ return nil unless @doc
34
+
35
+ if parts.length == 1
36
+ case parts[0]
37
+ when "body" then @doc.body
38
+ when "slug" then @doc.slug
39
+ when "date" then @doc.date
40
+ when "filePath" then @doc.file_path
41
+ when "collection" then @doc.collection
42
+ else @doc.metadata[parts[0]]
43
+ end
44
+ else
45
+ val = @doc.metadata[parts[0]]
46
+ dig(val, parts[1..])
47
+ end
48
+ end
49
+
50
+ def resolve_collections(parts)
51
+ return @collections if parts.empty?
52
+
53
+ coll = @collections[parts[0]]
54
+ return coll if parts.length == 1
55
+
56
+ coll
57
+ end
58
+
59
+ def dig(value, parts)
60
+ return value if parts.nil? || parts.empty?
61
+
62
+ current = value
63
+ parts.each do |part|
64
+ case current
65
+ when Document
66
+ current = resolve_document_property(current, part)
67
+ when Hash
68
+ current = current[part] || current[part.to_sym]
69
+ when Array
70
+ idx = Integer(part, exception: false)
71
+ current = idx ? current[idx] : nil
72
+ else
73
+ return nil
74
+ end
75
+ end
76
+ current
77
+ end
78
+
79
+ def resolve_document_property(document, part)
80
+ case part
81
+ when "body" then document.body
82
+ when "slug" then document.slug
83
+ when "date" then document.date
84
+ when "filePath" then document.file_path
85
+ when "collection" then document.collection
86
+ else document.metadata[part]
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end