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.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +35 -0
- data/PUBLISH.md +34 -0
- data/beachio-hammer.gemspec +27 -0
- data/exe/hammer +6 -0
- data/lib/beachio-hammer.rb +25 -0
- data/lib/hammer/build_command.rb +146 -0
- data/lib/hammer/builder.rb +195 -0
- data/lib/hammer/cli.rb +27 -0
- data/lib/hammer/content/builder.rb +77 -0
- data/lib/hammer/content/config.rb +68 -0
- data/lib/hammer/content/document.rb +115 -0
- data/lib/hammer/content/expression_evaluator.rb +113 -0
- data/lib/hammer/content/helpers.rb +96 -0
- data/lib/hammer/content/page_generator.rb +362 -0
- data/lib/hammer/content/parser.rb +463 -0
- data/lib/hammer/content/relation_resolver.rb +54 -0
- data/lib/hammer/content/template_context.rb +91 -0
- data/lib/hammer/css_parser.rb +178 -0
- data/lib/hammer/errors.rb +18 -0
- data/lib/hammer/ignore.rb +52 -0
- data/lib/hammer/output_formatter.rb +33 -0
- data/lib/hammer/project_resolver.rb +41 -0
- data/lib/hammer/sitemap_generator.rb +84 -0
- data/lib/hammer/tag_parser.rb +569 -0
- data/lib/hammer/version.rb +5 -0
- data/scripts/capture-goldens.sh +15 -0
- data/scripts/test-classic.rb +23 -0
- data/scripts/test-content.rb +20 -0
- data/scripts/tree-diff.rb +65 -0
- data/spec/parity/classic_spec.rb +23 -0
- data/spec/parity/content_spec.rb +23 -0
- data/spec/spec_helper.rb +10 -0
- metadata +103 -0
|
@@ -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"
|