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