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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6fbc6c05d882741fa94434691763470a2a8ae5cec91713e0b0fb9e4e0cd25bca
4
+ data.tar.gz: 22394aa9fae0d7b44a53d511a94a7a02a052c6cab1af769f51601e4c55adf166
5
+ SHA512:
6
+ metadata.gz: 8703bd2e45433905cdd2d503d70e7179d5aed28c35a6f2c32f1418dafd4267c80bd0cb6162531c18c61b0a426bd2f5a7f7d9d361a68f8caed04c414f375d9071
7
+ data.tar.gz: 62021958be80b18b3bd59059dc339399ca80650d1d9ea0e07d4f908093fb21f0ac8a95981d848f9495bd556ce3718e63e799186f21a28881d985c0f686d2b292
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.5
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,35 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ beachio-hammer (0.1.0.pre1)
5
+ json (~> 2.7)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ diff-lcs (1.6.2)
11
+ json (2.21.2)
12
+ rspec (3.13.2)
13
+ rspec-core (~> 3.13.0)
14
+ rspec-expectations (~> 3.13.0)
15
+ rspec-mocks (~> 3.13.0)
16
+ rspec-core (3.13.6)
17
+ rspec-support (~> 3.13.0)
18
+ rspec-expectations (3.13.5)
19
+ diff-lcs (>= 1.2.0, < 2.0)
20
+ rspec-support (~> 3.13.0)
21
+ rspec-mocks (3.13.8)
22
+ diff-lcs (>= 1.2.0, < 2.0)
23
+ rspec-support (~> 3.13.0)
24
+ rspec-support (3.13.7)
25
+
26
+ PLATFORMS
27
+ ruby
28
+ x86_64-darwin-24
29
+
30
+ DEPENDENCIES
31
+ beachio-hammer!
32
+ rspec (~> 3.13)
33
+
34
+ BUNDLED WITH
35
+ 2.6.9
data/PUBLISH.md ADDED
@@ -0,0 +1,34 @@
1
+ # Publishing beachio-hammer
2
+
3
+ ## Local development
4
+
5
+ ```bash
6
+ cd gem
7
+ bundle install
8
+ bundle exec rspec
9
+ ```
10
+
11
+ ## Capture golden Build/ trees (macOS Swift CLI)
12
+
13
+ ```bash
14
+ ./scripts/capture-goldens.sh
15
+ ```
16
+
17
+ ## Publish to RubyGems
18
+
19
+ 1. Bump `Hammer::VERSION` in `lib/hammer/version.rb`.
20
+ 2. Build and push:
21
+
22
+ ```bash
23
+ cd gem
24
+ gem build beachio-hammer.gemspec
25
+ gem push beachio-hammer-*.gem
26
+ ```
27
+
28
+ ## hammer-cloud integration
29
+
30
+ Pin the published gem version in the executor Dockerfile:
31
+
32
+ ```dockerfile
33
+ RUN gem install beachio-hammer -v 0.1.0.pre1
34
+ ```
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/hammer/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "beachio-hammer"
7
+ spec.version = Hammer::VERSION
8
+ spec.authors = ["Beach.io"]
9
+ spec.email = ["support@beach.io"]
10
+ spec.summary = "Hammer static site compiler (Ruby port of Hammer-Swift)"
11
+ spec.description = "Build static sites with Hammer tags, CSS includes, and content collections."
12
+ spec.homepage = "https://github.com/beachio/hammer-swift"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.4.0"
15
+
16
+ spec.files = Dir.chdir(__dir__) do
17
+ tracked = `git ls-files -z 2>/dev/null`.split("\x0")
18
+ tracked = Dir["lib/**/*.rb", "exe/*", "scripts/*"] if tracked.empty?
19
+ tracked.reject { |f| f.end_with?(".gem") || f.start_with?("spec/fixtures/") }
20
+ end
21
+ spec.bindir = "exe"
22
+ spec.executables = ["hammer"]
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.add_dependency "json", "~> 2.7"
26
+ spec.add_development_dependency "rspec", "~> 3.13"
27
+ end
data/exe/hammer ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "beachio-hammer"
5
+
6
+ exit Hammer::CLI.run(ARGV)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hammer/version"
4
+ require_relative "hammer/errors"
5
+ require_relative "hammer/ignore"
6
+ require_relative "hammer/tag_parser"
7
+ require_relative "hammer/css_parser"
8
+ require_relative "hammer/sitemap_generator"
9
+ require_relative "hammer/project_resolver"
10
+ require_relative "hammer/output_formatter"
11
+ require_relative "hammer/content/config"
12
+ require_relative "hammer/content/document"
13
+ require_relative "hammer/content/parser"
14
+ require_relative "hammer/content/helpers"
15
+ require_relative "hammer/content/template_context"
16
+ require_relative "hammer/content/expression_evaluator"
17
+ require_relative "hammer/content/relation_resolver"
18
+ require_relative "hammer/content/page_generator"
19
+ require_relative "hammer/content/builder"
20
+ require_relative "hammer/builder"
21
+ require_relative "hammer/build_command"
22
+ require_relative "hammer/cli"
23
+
24
+ module Hammer
25
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hammer
4
+ module BuildCommand
5
+ module_function
6
+
7
+ def run(argv)
8
+ options = parse_options(argv)
9
+ project_dir = ProjectResolver.resolve(explicit_path: options[:project])
10
+ site = ProjectResolver.create_site(project_dir, output: options[:output])
11
+
12
+ unless options[:quiet]
13
+ puts "Building #{site[:name]}..."
14
+ puts " Source: #{site[:source_path]}"
15
+ puts " Output: #{site[:build_path]}"
16
+ puts ""
17
+ end
18
+
19
+ delegate = BuildDelegate.new(format: options[:format], quiet: options[:quiet])
20
+ builder = Builder.new(
21
+ site: site,
22
+ mode: options[:mode],
23
+ log_level: options[:log_level],
24
+ clean: options[:clean],
25
+ delegate: delegate
26
+ )
27
+
28
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
+ builder.build
30
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
31
+
32
+ OutputFormatter.format_build_result(
33
+ duration: duration,
34
+ file_count: delegate.file_count,
35
+ error_count: delegate.error_count,
36
+ warning_count: delegate.warning_count,
37
+ build_size: format_size(site[:build_path]),
38
+ errors: delegate.errors,
39
+ warnings: delegate.warnings,
40
+ format: options[:format]
41
+ )
42
+
43
+ delegate.error_count.positive? ? 1 : 0
44
+ end
45
+
46
+ def parse_options(argv)
47
+ options = {
48
+ project: nil,
49
+ output: nil,
50
+ mode: :export,
51
+ format: :human,
52
+ log_level: :normal,
53
+ clean: false,
54
+ quiet: false,
55
+ verbose: false
56
+ }
57
+
58
+ i = 0
59
+ while i < argv.length
60
+ arg = argv[i]
61
+ case arg
62
+ when "--project"
63
+ options[:project] = argv[i += 1]
64
+ when "--output"
65
+ options[:output] = argv[i += 1]
66
+ when "--mode"
67
+ mode = argv[i += 1].to_s.downcase
68
+ options[:mode] = case mode
69
+ when "normal" then :normal
70
+ when "export" then :export
71
+ when "publish" then :publish
72
+ else
73
+ warn "Error: Unknown build mode '#{mode}'. Use: normal, export, or publish"
74
+ raise ConfigError, "Unknown build mode"
75
+ end
76
+ when "--format"
77
+ fmt = argv[i += 1].to_s.downcase
78
+ options[:format] = fmt == "json" ? :json : :human
79
+ when "--clean"
80
+ options[:clean] = true
81
+ when "--verbose"
82
+ options[:verbose] = true
83
+ options[:log_level] = :verbose
84
+ when "--quiet"
85
+ options[:quiet] = true
86
+ options[:log_level] = :quiet
87
+ else
88
+ warn "Unknown option: #{arg}"
89
+ raise ConfigError, "Unknown option: #{arg}"
90
+ end
91
+ i += 1
92
+ end
93
+
94
+ options
95
+ end
96
+
97
+ def format_size(path)
98
+ return "0 KB" unless File.directory?(path)
99
+
100
+ total = Dir.glob(File.join(path, "**", "*"), File::FNM_DOTMATCH).sum do |f|
101
+ File.file?(f) ? File.size(f) : 0
102
+ end
103
+ if total >= 1_048_576
104
+ "#{(total / 1_048_576.0).round(1)} MB"
105
+ else
106
+ "#{(total / 1024.0).round(1)} KB"
107
+ end
108
+ end
109
+
110
+ class BuildDelegate
111
+ attr_reader :file_count, :error_count, :warning_count, :errors, :warnings
112
+
113
+ def initialize(format:, quiet:)
114
+ @format = format
115
+ @quiet = quiet
116
+ @file_count = 0
117
+ @error_count = 0
118
+ @warning_count = 0
119
+ @errors = []
120
+ @warnings = []
121
+ end
122
+
123
+ def log(message)
124
+ puts message unless @quiet
125
+ end
126
+
127
+ def processed_file(_source, _dest)
128
+ @file_count += 1
129
+ end
130
+
131
+ def ignored_file(file, reason)
132
+ log("Ignored #{file}: #{reason}") unless @quiet
133
+ end
134
+
135
+ def error(file, message)
136
+ @error_count += 1
137
+ @errors << { file: file, message: message }
138
+ end
139
+
140
+ def warning(file, message)
141
+ @warning_count += 1
142
+ @warnings << { file: file, message: message }
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Hammer
6
+ class Builder
7
+ def initialize(site:, mode: :export, log_level: :normal, clean: false, delegate: nil)
8
+ @site = site
9
+ @source = site[:source_path]
10
+ @build = site[:build_path]
11
+ @mode = mode
12
+ @log_level = log_level
13
+ @clean = clean
14
+ @delegate = delegate
15
+ @ignore_patterns = Ignore.load_patterns(@source)
16
+ @content_collections = {}
17
+ @content_config = nil
18
+ end
19
+
20
+ def build
21
+ clear_build_directory if @clean || %i[export publish].include?(@mode)
22
+
23
+ run_content_generation if Content::Config.enabled?(@source)
24
+ copy_public_directory
25
+ files = enumerate_files
26
+ process_files(files)
27
+ generate_sitemap
28
+ end
29
+
30
+ private
31
+
32
+ def clear_build_directory
33
+ FileUtils.rm_rf(@build) if File.directory?(@build)
34
+ FileUtils.mkdir_p(@build)
35
+ end
36
+
37
+ def run_content_generation
38
+ content_builder = Content::Builder.new(
39
+ source_directory: @source,
40
+ build_mode: @mode,
41
+ delegate: @delegate
42
+ )
43
+ content_builder.load_config!
44
+ content_builder.parse_all_content!
45
+
46
+ resolver = Content::RelationResolver.new(
47
+ collections: content_builder.collections,
48
+ config: content_builder.config
49
+ )
50
+ resolver.resolve_all!
51
+
52
+ generator = Content::PageGenerator.new(
53
+ source_directory: @source,
54
+ build_directory: @build,
55
+ config: content_builder.config,
56
+ collections: content_builder.collections,
57
+ build_mode: @mode,
58
+ delegate: @delegate
59
+ )
60
+ generator.generate_all_pages!
61
+
62
+ @content_collections = content_builder.collections
63
+ @content_config = content_builder.config
64
+ rescue StandardError => e
65
+ @delegate&.error("content.config.json", e.message)
66
+ end
67
+
68
+ def copy_public_directory
69
+ src = File.join(@source, "public")
70
+ dest = File.join(@build, "public")
71
+ return unless File.directory?(src)
72
+
73
+ FileUtils.rm_rf(dest) if File.directory?(dest)
74
+ FileUtils.cp_r(src, dest)
75
+ end
76
+
77
+ def enumerate_files
78
+ files = []
79
+ content_enabled = Content::Config.enabled?(@source)
80
+ build_prefix = @build.delete_prefix("#{@source}/").delete_prefix("/")
81
+ build_prefix = "#{build_prefix}/" unless build_prefix.empty?
82
+
83
+ Dir.glob(File.join(@source, "**", "*"), File::FNM_DOTMATCH).each do |abs|
84
+ next unless File.file?(abs)
85
+
86
+ rel = abs.delete_prefix("#{@source}/")
87
+ next if rel.start_with?("Build/", ".hammer-cache/", ".")
88
+ next if !build_prefix.empty? && (rel == build_prefix.chomp("/") || rel.start_with?(build_prefix))
89
+
90
+ if content_enabled
91
+ next if rel.start_with?("content/", "templates/", "public/")
92
+ end
93
+
94
+ skip, reason = Ignore.should_skip?(rel, @ignore_patterns)
95
+ if skip
96
+ @delegate&.ignored_file(rel, reason)
97
+ next
98
+ end
99
+
100
+ files << rel
101
+ end
102
+ files.sort
103
+ end
104
+
105
+ def build_skip_prefixes
106
+ prefixes = ["Build/"]
107
+ if @build.start_with?(@source)
108
+ rel = @build.delete_prefix("#{@source}/")
109
+ prefixes << "#{rel}/" unless rel.empty?
110
+ end
111
+ prefixes
112
+ end
113
+
114
+ def process_files(files)
115
+ tag_parser = TagParser.new(
116
+ source_directory: @source,
117
+ build_mode: @mode,
118
+ delegate: @delegate,
119
+ content_collections: @content_collections,
120
+ content_config: @content_config,
121
+ skip_path_prefixes: build_skip_prefixes
122
+ )
123
+ css_parser = CSSParser.new(source_directory: @source, skip_path_prefixes: build_skip_prefixes)
124
+
125
+ files.each do |rel|
126
+ process_file(rel, tag_parser, css_parser)
127
+ end
128
+ end
129
+
130
+ def process_file(rel, tag_parser, css_parser)
131
+ src = File.join(@source, rel)
132
+ dest = File.join(@build, rel)
133
+ ext = File.extname(rel).downcase
134
+
135
+ case ext
136
+ when ".html"
137
+ content = File.read(src)
138
+ if !@content_collections.empty? && content.include?("@loop") && content.include?("collections.")
139
+ content = Content::PageGenerator.pre_process_collection_loops(
140
+ content,
141
+ collections: @content_collections,
142
+ source_directory: @source,
143
+ build_mode: @mode,
144
+ delegate: @delegate
145
+ )
146
+ end
147
+ processed = tag_parser.process_file_content(content, rel)
148
+ FileUtils.mkdir_p(File.dirname(dest))
149
+ File.write(dest, processed)
150
+ when ".css"
151
+ processed = css_parser.parse(File.read(src), relative_path: rel)
152
+ FileUtils.mkdir_p(File.dirname(dest))
153
+ File.write(dest, processed)
154
+ else
155
+ FileUtils.mkdir_p(File.dirname(dest))
156
+ FileUtils.cp(src, dest)
157
+ end
158
+
159
+ @delegate&.processed_file(rel, dest)
160
+ rescue StandardError => e
161
+ @delegate&.error(rel, e.message)
162
+ end
163
+
164
+ def generate_sitemap
165
+ content_paths = []
166
+ if @content_config && @content_collections.any?
167
+ generator = Content::PageGenerator.new(
168
+ source_directory: @source,
169
+ build_directory: @build,
170
+ config: @content_config,
171
+ collections: @content_collections,
172
+ build_mode: @mode
173
+ )
174
+ @content_collections.each do |name, coll|
175
+ cfg = @content_config.get_collection(name)
176
+ next unless cfg
177
+
178
+ coll.documents.each do |doc|
179
+ path = generator.resolve_output_path(doc, cfg)
180
+ content_paths << path if File.file?(File.join(@build, path))
181
+ end
182
+ end
183
+ end
184
+
185
+ exclude = Set.new(content_paths.map { |p| SitemapGenerator.new(build_directory: @build).normalize_path(p) })
186
+ static = SitemapGenerator.new(build_directory: @build).discover_static_pages(exclude: exclude)
187
+ SitemapGenerator.new(build_directory: @build).generate!(
188
+ content_paths: content_paths,
189
+ static_paths: static
190
+ )
191
+ end
192
+ end
193
+ end
194
+
195
+ require "set"
data/lib/hammer/cli.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hammer
4
+ module CLI
5
+ module_function
6
+
7
+ def run(argv)
8
+ command = argv.first == "build" ? argv.shift : "build"
9
+ case command
10
+ when "build"
11
+ BuildCommand.run(argv)
12
+ else
13
+ warn "Unknown command: #{command}"
14
+ 2
15
+ end
16
+ rescue ConfigError => e
17
+ warn "Error: #{e.message}"
18
+ 2
19
+ rescue InternalError => e
20
+ warn "Internal error: #{e.message}"
21
+ 3
22
+ rescue BuildError => e
23
+ warn "Build failed: #{e.message}"
24
+ 1
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hammer
4
+ module Content
5
+ class Builder
6
+ attr_reader :config, :collections
7
+
8
+ def initialize(source_directory:, build_mode:, delegate: nil)
9
+ @source_directory = source_directory
10
+ @build_mode = build_mode
11
+ @delegate = delegate
12
+ @parser = Parser.new(source_directory: source_directory)
13
+ @config = nil
14
+ @collections = {}
15
+ end
16
+
17
+ def load_config!
18
+ path = File.join(@source_directory, "content.config.json")
19
+ raise ConfigError, "Content config not found" unless File.file?(path)
20
+
21
+ @config = Config.load(path)
22
+ end
23
+
24
+ def parse_all_content!
25
+ raise ConfigError, "Config not loaded" unless @config
26
+
27
+ @collections.clear
28
+ @config.collections.each do |name, collection_config|
29
+ files = discover_content_files(collection_config)
30
+ collection = Collection.new(name)
31
+ files.each do |file|
32
+ doc = parse_file(file)
33
+ next unless doc
34
+ next unless should_process?(doc)
35
+
36
+ collection.add(doc)
37
+ end
38
+ @collections[name] = collection
39
+ end
40
+ end
41
+
42
+ def discover_content_files(collection_config)
43
+ dir = File.join(@source_directory, collection_config.path)
44
+ return [] unless File.directory?(dir)
45
+
46
+ ext = collection_config.format&.downcase
47
+ Dir.glob(File.join(dir, "**", "*")).select do |f|
48
+ next false unless File.file?(f)
49
+
50
+ e = File.extname(f).delete_prefix(".").downcase
51
+ case ext
52
+ when "md", "markdown" then e == "md"
53
+ when "yaml", "yml" then %w[yaml yml].include?(e)
54
+ else %w[md yaml yml].include?(e)
55
+ end
56
+ end
57
+ end
58
+
59
+ def parse_file(path)
60
+ ext = File.extname(path).delete_prefix(".").downcase
61
+ case ext
62
+ when "md" then @parser.parse_markdown_file(path)
63
+ when "yaml", "yml" then @parser.parse_yaml_file(path)
64
+ end
65
+ rescue StandardError => e
66
+ @delegate&.error(path, e.message)
67
+ nil
68
+ end
69
+
70
+ def should_process?(doc)
71
+ return true if doc.published?
72
+
73
+ @build_mode == :normal
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hammer
6
+ module Content
7
+ FieldConfig = Struct.new(:type, :required, :default, :collection, :value_field, keyword_init: true) do
8
+ def self.from_hash(hash)
9
+ collection = hash["collection"] || hash["to"]
10
+ new(
11
+ type: hash["type"],
12
+ required: hash["required"],
13
+ default: hash["default"],
14
+ collection: collection,
15
+ value_field: hash["valueField"]
16
+ )
17
+ end
18
+ end
19
+
20
+ CollectionConfig = Struct.new(:path, :format, :template, :output_path, :output, :slug, :fields, keyword_init: true) do
21
+ def output_path_pattern
22
+ output&.dig("filename") || output_path
23
+ end
24
+
25
+ def self.from_hash(hash)
26
+ fields = (hash["fields"] || {}).transform_values { |f| FieldConfig.from_hash(f) }
27
+ new(
28
+ path: hash["path"],
29
+ format: hash["format"],
30
+ template: hash["template"],
31
+ output_path: hash["outputPath"],
32
+ output: hash["output"],
33
+ slug: hash["slug"],
34
+ fields: fields
35
+ )
36
+ end
37
+ end
38
+
39
+ class Config
40
+ attr_reader :collections, :site_url
41
+
42
+ def initialize(collections:, site_url: nil)
43
+ @collections = collections
44
+ @site_url = site_url
45
+ end
46
+
47
+ def self.load(path)
48
+ data = JSON.parse(File.read(path))
49
+ collections = data.fetch("collections", {}).transform_values { |c| CollectionConfig.from_hash(c) }
50
+ new(collections: collections, site_url: data["siteUrl"])
51
+ end
52
+
53
+ def self.enabled?(source_dir)
54
+ path = File.join(source_dir, "content.config.json")
55
+ return false unless File.file?(path)
56
+
57
+ cfg = load(path)
58
+ !cfg.collections.empty?
59
+ rescue StandardError
60
+ false
61
+ end
62
+
63
+ def get_collection(name)
64
+ @collections[name]
65
+ end
66
+ end
67
+ end
68
+ end