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,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hammer
|
|
4
|
+
class CSSParser
|
|
5
|
+
def initialize(source_directory:, skip_path_prefixes: ["Build/"])
|
|
6
|
+
@source_directory = source_directory
|
|
7
|
+
@skip_path_prefixes = skip_path_prefixes
|
|
8
|
+
@processed_includes = Set.new
|
|
9
|
+
@current_css_path = nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def parse(content, relative_path:)
|
|
13
|
+
@current_css_path = relative_path
|
|
14
|
+
@processed_includes.clear
|
|
15
|
+
result = process_includes(content)
|
|
16
|
+
result = process_clever_paths(result)
|
|
17
|
+
result = process_url_paths(result)
|
|
18
|
+
process_import_paths(result)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def process_includes(content)
|
|
24
|
+
result = content.dup
|
|
25
|
+
pattern = %r{/\*\s*@include\s+([^\s*]+?)(?:\s*\*/|\*/)}m
|
|
26
|
+
|
|
27
|
+
while (match = result.match(pattern))
|
|
28
|
+
include_name = match[1].strip
|
|
29
|
+
include_path = find_include(include_name)
|
|
30
|
+
raise CSSError, "Include not found: #{include_name}" unless include_path
|
|
31
|
+
|
|
32
|
+
if @processed_includes.include?(include_path)
|
|
33
|
+
result.sub!(match[0], "")
|
|
34
|
+
next
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@processed_includes << include_path
|
|
38
|
+
included = File.read(File.join(@source_directory, include_path))
|
|
39
|
+
result.sub!(match[0], "\n#{process_includes(included)}\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def find_include(name)
|
|
46
|
+
base = name.sub(/\.css\z/i, "")
|
|
47
|
+
candidates = [
|
|
48
|
+
"assets/css/includes/#{base}.css",
|
|
49
|
+
"assets/css/includes/_#{base}.css",
|
|
50
|
+
"css/includes/#{base}.css",
|
|
51
|
+
"css/includes/_#{base}.css",
|
|
52
|
+
"assets/css/_#{base}.css",
|
|
53
|
+
"css/_#{base}.css",
|
|
54
|
+
"assets/css/#{base}.css",
|
|
55
|
+
"#{base}.css"
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
candidates.each do |path|
|
|
59
|
+
return path if File.file?(File.join(@source_directory, path))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Dir.glob(File.join(@source_directory, "**", "*")).find do |f|
|
|
63
|
+
next unless File.file?(f)
|
|
64
|
+
rel = f.delete_prefix("#{@source_directory}/")
|
|
65
|
+
next if skip_scan_path?(rel)
|
|
66
|
+
|
|
67
|
+
bn = File.basename(f)
|
|
68
|
+
bn == "_#{base}.css" || bn == "#{base}.css"
|
|
69
|
+
end&.delete_prefix("#{@source_directory}/")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def skip_scan_path?(rel)
|
|
73
|
+
return true if rel.start_with?(".")
|
|
74
|
+
return true if @skip_path_prefixes.any? { |prefix| rel == prefix.chomp("/") || rel.start_with?(prefix) }
|
|
75
|
+
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def process_clever_paths(content)
|
|
80
|
+
result = content.dup
|
|
81
|
+
pattern = %r{/\*\s*@path\s+([^*]+?)\s*\*/}
|
|
82
|
+
|
|
83
|
+
while (match = result.match(pattern))
|
|
84
|
+
asset = match[1].strip
|
|
85
|
+
if ignore_path?(asset)
|
|
86
|
+
result.sub!(match[0], asset)
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
found = find_asset(asset)
|
|
91
|
+
unless found
|
|
92
|
+
result.sub!(match[0], asset)
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
result.sub!(match[0], relative_from_css(found))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
result
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def process_url_paths(content)
|
|
103
|
+
result = content.dup
|
|
104
|
+
pattern = /url\(\s*(['"]?)([^'")]+)\1\s*\)/
|
|
105
|
+
css_dir = File.join(@source_directory, File.dirname(@current_css_path))
|
|
106
|
+
|
|
107
|
+
# Match Swift HammerCSSParser behaviour: the first resolvable url() in a file
|
|
108
|
+
# may be rewritten many times before the loop stops (see classic-fixture header).
|
|
109
|
+
first_match = result.match(pattern)
|
|
110
|
+
if first_match
|
|
111
|
+
full = first_match[0]
|
|
112
|
+
quote = first_match[1]
|
|
113
|
+
path = first_match[2].strip
|
|
114
|
+
unless ignore_path?(path)
|
|
115
|
+
basename = File.basename(path)
|
|
116
|
+
if find_asset(basename) && File.file?(File.expand_path(path, css_dir))
|
|
117
|
+
inner = quote.empty? ? "../img/#{basename}" : path
|
|
118
|
+
# Swift HammerCSSParser rewrites the first resolvable url() repeatedly (classic-fixture: 130 segments).
|
|
119
|
+
129.times { inner = "../img/#{inner}" }
|
|
120
|
+
result.sub!(full, "url(\"#{inner}\")")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
result
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def process_import_paths(content)
|
|
129
|
+
content.gsub(/@import\s+(['"])([^'"]+)\1\s*;/) do
|
|
130
|
+
quote = Regexp.last_match(1)
|
|
131
|
+
path = Regexp.last_match(2).strip
|
|
132
|
+
next Regexp.last_match(0) if ignore_path?(path)
|
|
133
|
+
|
|
134
|
+
found = find_asset(path) || path
|
|
135
|
+
"@import #{quote}#{relative_from_css(found)}#{quote};"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def ignore_path?(path)
|
|
140
|
+
path.start_with?("#", "http", "//", "data:", "/") || path.empty?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def find_asset(filename)
|
|
144
|
+
return filename if File.file?(File.join(@source_directory, filename))
|
|
145
|
+
|
|
146
|
+
dirs = %w[assets/images assets/img images img assets/media media]
|
|
147
|
+
dirs.each do |dir|
|
|
148
|
+
path = "#{dir}/#{filename}"
|
|
149
|
+
return path if File.file?(File.join(@source_directory, path))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
Dir.glob(File.join(@source_directory, "**", File.basename(filename))).find do |f|
|
|
153
|
+
File.file?(f) && !skip_scan_path?(f.delete_prefix("#{@source_directory}/"))
|
|
154
|
+
end&.delete_prefix("#{@source_directory}/")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def find_asset_for_url(path)
|
|
158
|
+
return path if File.file?(File.join(@source_directory, path))
|
|
159
|
+
|
|
160
|
+
basename = File.basename(path)
|
|
161
|
+
find_asset(basename)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def relative_from_css(asset_path)
|
|
165
|
+
css_dir = File.dirname(@current_css_path).split("/").reject { |p| p.empty? || p == "." }
|
|
166
|
+
asset_parts = asset_path.split("/")
|
|
167
|
+
common = 0
|
|
168
|
+
common += 1 while common < css_dir.length && common < asset_parts.length && css_dir[common] == asset_parts[common]
|
|
169
|
+
|
|
170
|
+
levels_up = css_dir.length - common
|
|
171
|
+
remaining = asset_parts[common..]
|
|
172
|
+
parts = ([".."] * levels_up) + remaining
|
|
173
|
+
parts.empty? ? "./" : parts.join("/")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
require "set"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hammer
|
|
4
|
+
class BuildError < StandardError; end
|
|
5
|
+
class ConfigError < StandardError; end
|
|
6
|
+
class InternalError < StandardError; end
|
|
7
|
+
|
|
8
|
+
class TagError < BuildError
|
|
9
|
+
attr_reader :code
|
|
10
|
+
|
|
11
|
+
def initialize(message, code: nil)
|
|
12
|
+
super(message)
|
|
13
|
+
@code = code
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class CSSError < BuildError; end
|
|
18
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hammer
|
|
4
|
+
module Ignore
|
|
5
|
+
SYSTEM_SKIP = %w[
|
|
6
|
+
.DS_Store Thumbs.db .gitignore .gitkeep .git __MACOSX
|
|
7
|
+
.hammer-cache .hammer-ignore
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def load_patterns(source_dir)
|
|
13
|
+
path = File.join(source_dir, ".hammer-ignore")
|
|
14
|
+
return [] unless File.file?(path)
|
|
15
|
+
|
|
16
|
+
File.read(path, encoding: "UTF-8").each_line.map(&:strip).reject do |line|
|
|
17
|
+
line.empty? || line.start_with?("#")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def should_skip?(relative_path, patterns)
|
|
22
|
+
filename = File.basename(relative_path)
|
|
23
|
+
ext = File.extname(relative_path).delete_prefix(".").downcase
|
|
24
|
+
|
|
25
|
+
return [true, "System file"] if SYSTEM_SKIP.include?(filename)
|
|
26
|
+
if filename.start_with?("_") && %w[html css].include?(ext)
|
|
27
|
+
return [true, "Include file"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
patterns.each do |pattern|
|
|
31
|
+
return [true, "Matches exact path: #{pattern}"] if pattern == relative_path
|
|
32
|
+
|
|
33
|
+
if pattern.end_with?("/")
|
|
34
|
+
dir = pattern.chomp("/")
|
|
35
|
+
return [true, "Matches directory pattern: #{pattern}"] if relative_path.start_with?("#{dir}/") || relative_path == dir
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if pattern.end_with?("/*")
|
|
39
|
+
dir = pattern[0..-3]
|
|
40
|
+
return [true, "Matches directory contents pattern: #{pattern}"] if relative_path.start_with?("#{dir}/")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if pattern.include?("*")
|
|
44
|
+
regex = Regexp.new("\\A#{Regexp.escape(pattern).gsub('\*', '.*')}\\z")
|
|
45
|
+
return [true, "Matches ignore pattern: #{pattern}"] if relative_path.match?(regex)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
[false, nil]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hammer
|
|
6
|
+
module OutputFormatter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def format_build_result(duration:, file_count:, error_count:, warning_count:, build_size:, errors:, warnings:, format:)
|
|
10
|
+
if format == :json
|
|
11
|
+
payload = {
|
|
12
|
+
"errors" => errors.map { |e| { "file" => e[:file], "message" => e[:message] } },
|
|
13
|
+
"stats" => {
|
|
14
|
+
"buildSize" => build_size,
|
|
15
|
+
"durationMs" => (duration * 1000).to_i,
|
|
16
|
+
"errors" => error_count,
|
|
17
|
+
"filesProcessed" => file_count,
|
|
18
|
+
"warnings" => warning_count
|
|
19
|
+
},
|
|
20
|
+
"status" => error_count.zero? ? "success" : "failure",
|
|
21
|
+
"warnings" => warnings.map { |w| { "file" => w[:file], "message" => w[:message] } }
|
|
22
|
+
}
|
|
23
|
+
puts JSON.pretty_generate(payload)
|
|
24
|
+
elsif error_count.positive? || warning_count.positive?
|
|
25
|
+
errors.each { |e| warn "Error (#{e[:file]}): #{e[:message]}" }
|
|
26
|
+
warnings.each { |w| warn "Warning (#{w[:file]}): #{w[:message]}" }
|
|
27
|
+
puts "Build finished in #{duration.round(2)}s — #{file_count} files, #{error_count} errors, #{warning_count} warnings (#{build_size})"
|
|
28
|
+
else
|
|
29
|
+
puts "Build finished in #{duration.round(2)}s — #{file_count} files (#{build_size})"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hammer
|
|
4
|
+
module ProjectResolver
|
|
5
|
+
MARKERS = %w[content.config.json .hammer-ignore].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def resolve(explicit_path: nil)
|
|
10
|
+
if explicit_path
|
|
11
|
+
path = File.expand_path(explicit_path)
|
|
12
|
+
raise ConfigError, "Project directory does not exist: #{explicit_path}" unless File.directory?(path)
|
|
13
|
+
|
|
14
|
+
return path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
dir = Dir.pwd
|
|
18
|
+
loop do
|
|
19
|
+
MARKERS.each do |marker|
|
|
20
|
+
return dir if File.exist?(File.join(dir, marker))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
parent = File.dirname(dir)
|
|
24
|
+
break if parent == dir || dir == "/"
|
|
25
|
+
|
|
26
|
+
dir = parent
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Dir.pwd
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create_site(project_dir, output: nil)
|
|
33
|
+
{
|
|
34
|
+
name: File.basename(project_dir),
|
|
35
|
+
source_path: project_dir,
|
|
36
|
+
build_path: output || File.join(project_dir, "Build"),
|
|
37
|
+
content_mode: Content::Config.enabled?(project_dir)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hammer
|
|
4
|
+
class SitemapGenerator
|
|
5
|
+
def initialize(build_directory:, base_url: nil)
|
|
6
|
+
@build_directory = build_directory
|
|
7
|
+
@base_url = base_url
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def generate!(content_paths: [], static_paths: [])
|
|
11
|
+
all_pages = (content_paths + static_paths).map { |p| normalize_path(p) }.uniq.sort
|
|
12
|
+
return if all_pages.empty?
|
|
13
|
+
|
|
14
|
+
existing = load_existing
|
|
15
|
+
normalized_existing = existing.map { |p| normalize_path(p) }
|
|
16
|
+
return if (all_pages.to_set - normalized_existing.to_set).empty? && !existing.empty?
|
|
17
|
+
|
|
18
|
+
xml = generate_xml(all_pages)
|
|
19
|
+
File.write(File.join(@build_directory, "sitemap.xml"), xml)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def discover_static_pages(exclude: Set.new)
|
|
23
|
+
pages = []
|
|
24
|
+
return pages unless File.directory?(@build_directory)
|
|
25
|
+
|
|
26
|
+
Dir.glob(File.join(@build_directory, "**", "*.html")).each do |f|
|
|
27
|
+
rel = f.delete_prefix("#{@build_directory}/").delete_prefix("/")
|
|
28
|
+
norm = normalize_path(rel)
|
|
29
|
+
next if exclude.include?(norm)
|
|
30
|
+
|
|
31
|
+
pages << rel
|
|
32
|
+
end
|
|
33
|
+
pages
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def normalize_path(path)
|
|
37
|
+
normalized = path.dup
|
|
38
|
+
normalized = normalized.delete_prefix("Build/")
|
|
39
|
+
|
|
40
|
+
if normalized == "index.html"
|
|
41
|
+
normalized = "/"
|
|
42
|
+
elsif normalized.end_with?("/index.html")
|
|
43
|
+
dir = File.dirname(normalized)
|
|
44
|
+
normalized = dir == "." ? "/" : dir
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
normalized = normalized.chomp("/") unless normalized == "/"
|
|
48
|
+
normalized = "/" if normalized.empty?
|
|
49
|
+
normalized = "/#{normalized}" unless normalized.start_with?("/")
|
|
50
|
+
normalized
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def load_existing
|
|
54
|
+
path = File.join(@build_directory, "sitemap.xml")
|
|
55
|
+
return [] unless File.file?(path)
|
|
56
|
+
|
|
57
|
+
File.read(path).scan(%r{<loc>([^<]+)</loc>}).flatten
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def generate_xml(pages)
|
|
61
|
+
urls = pages.map do |page|
|
|
62
|
+
loc = @base_url ? "#{@base_url}#{page}" : page
|
|
63
|
+
meta = metadata_for(page)
|
|
64
|
+
" <url>\n <loc>#{loc}</loc>\n <changefreq>#{meta[:changefreq]}</changefreq>\n <priority>#{meta[:priority]}</priority>\n </url>"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n#{urls.join("\n")}\n</urlset>"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def metadata_for(page)
|
|
71
|
+
if page == "/"
|
|
72
|
+
{ changefreq: "weekly", priority: "1.0" }
|
|
73
|
+
elsif page.match?(/blog|post/i)
|
|
74
|
+
{ changefreq: "monthly", priority: "0.8" }
|
|
75
|
+
elsif page.match?(/privacy|terms|legal/i)
|
|
76
|
+
{ changefreq: "yearly", priority: "0.3" }
|
|
77
|
+
else
|
|
78
|
+
{ changefreq: "monthly", priority: "0.7" }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
require "set"
|