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,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"