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,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("-->", "-->")
|
|
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?("<") || inner.include?(">")
|
|
134
|
+
|
|
135
|
+
escaped = inner.gsub("<", "<").gsub(">", ">")
|
|
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("<", "<").gsub(">", ">")
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
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
|