jekyll-test-harness 0.1.0.alpha
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/lib/jekyll_test_harness/core/configuration.rb +117 -0
- data/lib/jekyll_test_harness/core/data_tools.rb +41 -0
- data/lib/jekyll_test_harness/core/errors.rb +78 -0
- data/lib/jekyll_test_harness/core/file_tree.rb +31 -0
- data/lib/jekyll_test_harness/core/files_dsl.rb +157 -0
- data/lib/jekyll_test_harness/core/fixture_loader.rb +104 -0
- data/lib/jekyll_test_harness/core/jekyll_blueprint.rb +31 -0
- data/lib/jekyll_test_harness/core/paths.rb +94 -0
- data/lib/jekyll_test_harness/core/site_harness.rb +148 -0
- data/lib/jekyll_test_harness/core/temporary_directory.rb +101 -0
- data/lib/jekyll_test_harness/entrypoint.rb +19 -0
- data/lib/jekyll_test_harness/framework/helpers.rb +105 -0
- data/lib/jekyll_test_harness/framework/installer.rb +158 -0
- data/lib/jekyll_test_harness/version.rb +6 -0
- data/lib/jekyll_test_harness.rb +4 -0
- data/readme.md +231 -0
- metadata +134 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e4a625cf3a3297d317602eba915e696eba8dc382122cf1ea22f329e36bdd15b7
|
|
4
|
+
data.tar.gz: cfa090aa27f25047e324736879dba8dbbf53dbfec5ae0d23464f2a84c228ff44
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cc34a86b991338ce3088bd0a0cfd346d6f6bbf0625e9b16a4747de18eb1c7dcfc955f4a90640fc5a169badc06fd6334bb3a50ba2f53fecf1e54e42b79877d2ef
|
|
7
|
+
data.tar.gz: 073d5e32c6f0e110352b04c77ef76afa55778c89b7270b60c8652c321e690796a47eb7b9aed7a430d2ceedd19944c4650f0e6af4c0e5571ebe21ceae79434775
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module JekyllTestHarness
|
|
6
|
+
# Stores runtime harness settings and shared defaults used by SiteHarness.
|
|
7
|
+
module Configuration
|
|
8
|
+
DEFAULT_FAILURE_MODE = :clean
|
|
9
|
+
SUPPORTED_FAILURE_MODES = %i[clean keep].freeze
|
|
10
|
+
TEMPORARY_DIRECTORY_PREFIX = 'jekyll-test-harness'.freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Returns the baseline Jekyll configuration for a temporary site.
|
|
15
|
+
def default_config(source:, destination:)
|
|
16
|
+
{
|
|
17
|
+
'source' => source,
|
|
18
|
+
'destination' => destination,
|
|
19
|
+
'quiet' => true,
|
|
20
|
+
'incremental' => false
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Applies runtime settings for failure handling and site root location.
|
|
25
|
+
def configure_runtime!(failures:, output:, project_root:)
|
|
26
|
+
@failure_mode = normalise_failure_mode(failures)
|
|
27
|
+
@project_root = File.expand_path((project_root || Dir.pwd).to_s)
|
|
28
|
+
@output = normalise_output(output, @project_root)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Resets runtime settings to defaults.
|
|
32
|
+
def reset_runtime!
|
|
33
|
+
configure_runtime!(failures: DEFAULT_FAILURE_MODE, output: nil, project_root: Dir.pwd)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns the configured behaviour for failed Jekyll builds.
|
|
37
|
+
def failure_mode
|
|
38
|
+
@failure_mode || DEFAULT_FAILURE_MODE
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns true when failed build directories should be kept for debugging.
|
|
42
|
+
def keep_failures?
|
|
43
|
+
failure_mode == :keep
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Exposes the temporary directory prefix used for unnamed fallback directories.
|
|
47
|
+
def temporary_directory_prefix
|
|
48
|
+
TEMPORARY_DIRECTORY_PREFIX
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the project root captured during install!.
|
|
52
|
+
def project_root
|
|
53
|
+
@project_root || Dir.pwd
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns the configured base directory for build output roots, or nil for system temp.
|
|
57
|
+
def output
|
|
58
|
+
@output
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Validates and normalises failure mode values.
|
|
62
|
+
def normalise_failure_mode(failures)
|
|
63
|
+
selected_failures = failures.nil? ? DEFAULT_FAILURE_MODE : failures
|
|
64
|
+
normalised_failures = normalise_symbol_option(
|
|
65
|
+
option_name: 'failures',
|
|
66
|
+
value: selected_failures,
|
|
67
|
+
usage: 'Use `failures: :clean` (default) or `failures: :keep`.'
|
|
68
|
+
)
|
|
69
|
+
return normalised_failures if SUPPORTED_FAILURE_MODES.include?(normalised_failures)
|
|
70
|
+
|
|
71
|
+
raise ArgumentError, ValidationMessages.unsupported_value(
|
|
72
|
+
argument_name: 'failures',
|
|
73
|
+
value: failures,
|
|
74
|
+
supported_values: SUPPORTED_FAILURE_MODES,
|
|
75
|
+
usage: 'Use `failures: :clean` (default) or `failures: :keep`.'
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
private_class_method :normalise_failure_mode
|
|
79
|
+
|
|
80
|
+
# Expands custom output roots relative to project root.
|
|
81
|
+
def normalise_output(output, project_root)
|
|
82
|
+
return nil if output.nil?
|
|
83
|
+
|
|
84
|
+
output_path = normalise_path_argument(
|
|
85
|
+
argument_name: 'output',
|
|
86
|
+
value: output,
|
|
87
|
+
usage: "Use `nil` for system temp, a relative String like `'tmp/jekyll-sites'`, or an absolute path."
|
|
88
|
+
)
|
|
89
|
+
raise ArgumentError, '`output` must not be empty.' if output_path.strip.empty?
|
|
90
|
+
|
|
91
|
+
return File.expand_path(output_path) if Pathname.new(output_path).absolute?
|
|
92
|
+
|
|
93
|
+
File.expand_path(output_path, project_root)
|
|
94
|
+
end
|
|
95
|
+
private_class_method :normalise_output
|
|
96
|
+
|
|
97
|
+
# Normalises Symbol/String option values and rejects invalid input types.
|
|
98
|
+
def normalise_symbol_option(option_name:, value:, usage:)
|
|
99
|
+
return value if value.is_a?(Symbol)
|
|
100
|
+
return value.strip.to_sym if value.is_a?(String) && !value.strip.empty?
|
|
101
|
+
|
|
102
|
+
raise ArgumentError, ValidationMessages.type_error(argument_name: option_name, expected: 'a Symbol or non-empty String', value: value, usage: usage)
|
|
103
|
+
end
|
|
104
|
+
private_class_method :normalise_symbol_option
|
|
105
|
+
|
|
106
|
+
# Normalises path-like arguments and rejects unsupported input types.
|
|
107
|
+
def normalise_path_argument(argument_name:, value:, usage:)
|
|
108
|
+
return value if value.is_a?(String)
|
|
109
|
+
return value.to_path.to_s if value.respond_to?(:to_path)
|
|
110
|
+
|
|
111
|
+
raise ArgumentError, ValidationMessages.type_error(argument_name: argument_name, expected: 'a String path or Pathname', value: value, usage: usage)
|
|
112
|
+
end
|
|
113
|
+
private_class_method :normalise_path_argument
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
JekyllTestHarness::Configuration.reset_runtime!
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllTestHarness
|
|
4
|
+
# Provides deep clone and deep merge helpers used across config, files, and blueprints.
|
|
5
|
+
module DataTools
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Returns a deep clone so callers can safely mutate returned values.
|
|
9
|
+
def deep_clone(value)
|
|
10
|
+
case value
|
|
11
|
+
when Hash
|
|
12
|
+
value.each_with_object({}) do |(key, nested_value), clone|
|
|
13
|
+
clone[key] = deep_clone(nested_value)
|
|
14
|
+
end
|
|
15
|
+
when Array
|
|
16
|
+
value.map { |item| deep_clone(item) }
|
|
17
|
+
when String
|
|
18
|
+
value.dup
|
|
19
|
+
else
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Deep-merges hashes in the same spirit as ActiveSupport::Hash#deep_merge.
|
|
25
|
+
def deep_merge_hashes(base_hash, new_hash)
|
|
26
|
+
unless base_hash.is_a?(Hash) && new_hash.is_a?(Hash)
|
|
27
|
+
raise ArgumentError, "jekyll_merge hash inputs must both be Hash values. Received `base_hash`: #{ValidationMessages.describe_value(base_hash)} and `new_hash`: #{ValidationMessages.describe_value(new_hash)}. Usage: `jekyll_merge({ ... }, { ... })`."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
merged_hash = deep_clone(base_hash)
|
|
31
|
+
new_hash.each do |key, value|
|
|
32
|
+
merged_hash[key] = if merged_hash[key].is_a?(Hash) && value.is_a?(Hash)
|
|
33
|
+
deep_merge_hashes(merged_hash[key], value)
|
|
34
|
+
else
|
|
35
|
+
deep_clone(value)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
merged_hash
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllTestHarness
|
|
4
|
+
# Base error class for all harness-specific failures.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Raised when a block-based API is called without a block.
|
|
9
|
+
class MissingBlockError < Error
|
|
10
|
+
# Creates a clear error message for missing block usage.
|
|
11
|
+
def initialize(message = 'This method requires a block.')
|
|
12
|
+
super(message)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Wraps Jekyll build failures with context to help debugging.
|
|
17
|
+
class SiteBuildError < Error
|
|
18
|
+
attr_reader :source_path, :destination_path, :config_snapshot, :original_error
|
|
19
|
+
|
|
20
|
+
# Captures build context and the original exception that failed the build.
|
|
21
|
+
def initialize(cause:, source_path:, destination_path:, config_snapshot:)
|
|
22
|
+
@original_error = cause
|
|
23
|
+
@source_path = source_path
|
|
24
|
+
@destination_path = destination_path
|
|
25
|
+
@config_snapshot = config_snapshot
|
|
26
|
+
super(build_message)
|
|
27
|
+
set_backtrace(cause.backtrace)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Builds a diagnostic message that can be shown directly in failing specs.
|
|
33
|
+
def build_message
|
|
34
|
+
[
|
|
35
|
+
"Jekyll site build failed: #{original_error.class}: #{original_error.message}",
|
|
36
|
+
"Source path: #{source_path}",
|
|
37
|
+
"Destination path: #{destination_path}",
|
|
38
|
+
"Config snapshot: #{config_snapshot.inspect}",
|
|
39
|
+
'Hint: call JekyllTestHarness.install!(..., failures: :keep) to retain failed temporary sites for debugging.'
|
|
40
|
+
].join("\n")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Builds consistent, actionable validation messages for invalid harness usage.
|
|
45
|
+
module ValidationMessages
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# Describes a runtime value with a class name and a safely-truncated inspect payload.
|
|
49
|
+
def describe_value(value)
|
|
50
|
+
inspected_value = value.inspect
|
|
51
|
+
inspected_value = "#{inspected_value[0, 157]}..." if inspected_value.length > 160
|
|
52
|
+
"#{inspected_value} (#{value.class})"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Builds an error message for arguments that do not match the expected type.
|
|
56
|
+
def type_error(argument_name:, expected:, value:, usage: nil)
|
|
57
|
+
append_usage("#{argument_name} must be #{expected}. Received #{describe_value(value)}.", usage)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Builds an error message for unsupported option values.
|
|
61
|
+
def unsupported_value(argument_name:, value:, supported_values:, usage: nil)
|
|
62
|
+
supported_description = supported_values.map(&:inspect).join(', ')
|
|
63
|
+
append_usage("Unsupported value for #{argument_name}: #{describe_value(value)}. Supported values: #{supported_description}.", usage)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Builds an error message for APIs that require a block.
|
|
67
|
+
def missing_block(method_name:, usage:)
|
|
68
|
+
"#{method_name} requires a block. Usage: #{usage}."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Appends usage guidance only when it is present.
|
|
72
|
+
def append_usage(message, usage)
|
|
73
|
+
return message if usage.nil? || usage.to_s.strip.empty?
|
|
74
|
+
|
|
75
|
+
"#{message} #{usage}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module JekyllTestHarness
|
|
7
|
+
# Writes nested file trees used to construct temporary Jekyll sites.
|
|
8
|
+
module FileTree
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Writes a YAML file to disk, creating parent directories when needed.
|
|
12
|
+
def write_yaml(path, data)
|
|
13
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
14
|
+
File.write(path, data.to_yaml)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Writes a nested hash of files and directories under the supplied root.
|
|
18
|
+
def write(root, files)
|
|
19
|
+
files.each do |relative_path, contents|
|
|
20
|
+
full_path = File.join(root, relative_path.to_s)
|
|
21
|
+
if contents.is_a?(Hash)
|
|
22
|
+
FileUtils.mkdir_p(full_path)
|
|
23
|
+
write(full_path, contents)
|
|
24
|
+
else
|
|
25
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
26
|
+
File.write(full_path, contents.to_s)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module JekyllTestHarness
|
|
6
|
+
# Builds nested Jekyll source file hashes via a small folder/file DSL.
|
|
7
|
+
class FilesDsl
|
|
8
|
+
# Initialises the builder with a host context for delegated helper calls.
|
|
9
|
+
def initialize(host_context:, project_root:)
|
|
10
|
+
@host_context = host_context
|
|
11
|
+
@project_root = project_root
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Evaluates the DSL block and returns the resulting nested file hash.
|
|
15
|
+
def build(&block)
|
|
16
|
+
tree = {}
|
|
17
|
+
return tree if block.nil?
|
|
18
|
+
|
|
19
|
+
TreeContext.new(tree: tree, host_context: @host_context, project_root: @project_root).evaluate(&block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Supports nested folder/file declarations while delegating unknown methods to the test context.
|
|
23
|
+
class TreeContext
|
|
24
|
+
# Initialises a tree context for one folder level.
|
|
25
|
+
def initialize(tree:, host_context:, project_root:)
|
|
26
|
+
@tree = tree
|
|
27
|
+
@host_context = host_context
|
|
28
|
+
@project_root = project_root
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Creates a nested folder. The block can define additional folders or files.
|
|
32
|
+
def folder(name, &block)
|
|
33
|
+
folder_tree = {}
|
|
34
|
+
@tree[normalise_entry_name(name, entry_type: 'folder')] = folder_tree
|
|
35
|
+
return folder_tree if block.nil?
|
|
36
|
+
|
|
37
|
+
self.class.new(tree: folder_tree, host_context: @host_context, project_root: @project_root).evaluate(&block)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Creates a file where the block defines its final string contents.
|
|
41
|
+
def file(name, &block)
|
|
42
|
+
@tree[normalise_entry_name(name, entry_type: 'file')] = FileContext.new(host_context: @host_context, project_root: @project_root).evaluate(&block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Evaluates folder/file calls for this level.
|
|
46
|
+
def evaluate(&block)
|
|
47
|
+
instance_exec(&block)
|
|
48
|
+
@tree
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Delegates unknown DSL calls to the test context so helper composition still works.
|
|
54
|
+
def method_missing(method_name, *arguments, &block)
|
|
55
|
+
return @host_context.public_send(method_name, *arguments, &block) if @host_context && @host_context.respond_to?(method_name)
|
|
56
|
+
|
|
57
|
+
raise NoMethodError, "Unknown helper `#{method_name}` in jekyll_files tree context. Available DSL methods: `folder` and `file`."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Mirrors delegated method support checks for introspection.
|
|
61
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
62
|
+
(@host_context && @host_context.respond_to?(method_name, include_private)) || super
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Normalises file/folder names and rejects empty values that are hard to debug.
|
|
66
|
+
def normalise_entry_name(name, entry_type:)
|
|
67
|
+
entry_name = name.to_s
|
|
68
|
+
raise ArgumentError, "#{entry_type} name must not be empty." if entry_name.strip.empty?
|
|
69
|
+
|
|
70
|
+
entry_name
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Evaluates one file body, supporting either helper composition or direct return values.
|
|
75
|
+
class FileContext
|
|
76
|
+
# Initialises file content collection state.
|
|
77
|
+
def initialize(host_context:, project_root:)
|
|
78
|
+
@host_context = host_context
|
|
79
|
+
@project_root = project_root
|
|
80
|
+
@fragments = []
|
|
81
|
+
@used_content_helpers = false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Emits YAML front matter wrapped with separators and a trailing blank line.
|
|
85
|
+
def frontmatter(hash = nil, file: nil, **keyword_hash)
|
|
86
|
+
@used_content_helpers = true
|
|
87
|
+
fixture_hash = file.nil? ? {} : FixtureLoader.read_yaml_hash(file: file, project_root: @project_root)
|
|
88
|
+
inline_hash = coerce_hash(hash, argument_name: 'frontmatter hash')
|
|
89
|
+
inline_hash = DataTools.deep_merge_hashes(inline_hash, coerce_hash(keyword_hash, argument_name: 'frontmatter hash')) unless keyword_hash.empty?
|
|
90
|
+
merged_hash = DataTools.deep_merge_hashes(fixture_hash, inline_hash)
|
|
91
|
+
|
|
92
|
+
yaml_payload = YAML.dump(merged_hash).sub(/\A---[ \t]*\n?/, '')
|
|
93
|
+
fragment = "---\n#{yaml_payload}---\n\n"
|
|
94
|
+
@fragments << fragment
|
|
95
|
+
fragment
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Emits raw file contents directly from inline text and/or a fixture file.
|
|
99
|
+
def contents(text = nil, file: nil)
|
|
100
|
+
@used_content_helpers = true
|
|
101
|
+
fixture_text = file.nil? ? '' : FixtureLoader.read_text(file: file, project_root: @project_root)
|
|
102
|
+
inline_text = text.nil? ? '' : text.to_s
|
|
103
|
+
fragment = "#{fixture_text}#{inline_text}"
|
|
104
|
+
@fragments << fragment
|
|
105
|
+
fragment
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Evaluates the file block and returns a final string payload.
|
|
109
|
+
def evaluate(&block)
|
|
110
|
+
return '' if block.nil?
|
|
111
|
+
|
|
112
|
+
returned_value = instance_exec(&block)
|
|
113
|
+
return @fragments.join if @used_content_helpers
|
|
114
|
+
|
|
115
|
+
coerce_return_value(returned_value)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Converts nil/hash/string/array file return values into final file text.
|
|
121
|
+
def coerce_return_value(returned_value)
|
|
122
|
+
case returned_value
|
|
123
|
+
when nil
|
|
124
|
+
''
|
|
125
|
+
when String
|
|
126
|
+
returned_value
|
|
127
|
+
when Array
|
|
128
|
+
returned_value.map(&:to_s).join("\n")
|
|
129
|
+
when Hash
|
|
130
|
+
YAML.dump(returned_value)
|
|
131
|
+
else
|
|
132
|
+
raise ArgumentError, "File DSL block must return `String`, `Array`, `Hash`, or `nil` when not using `frontmatter`/`contents`. Received #{ValidationMessages.describe_value(returned_value)}."
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Normalises optional hash arguments into strict hash values.
|
|
137
|
+
def coerce_hash(value, argument_name:)
|
|
138
|
+
return {} if value.nil?
|
|
139
|
+
return value if value.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
raise ArgumentError, ValidationMessages.type_error(argument_name: argument_name, expected: 'a Hash', value: value, usage: "Pass `#{argument_name}` as a hash, for example: `#{argument_name}(title: 'My page')`.")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Delegates unknown helper calls to the outer test context.
|
|
145
|
+
def method_missing(method_name, *arguments, &block)
|
|
146
|
+
return @host_context.public_send(method_name, *arguments, &block) if @host_context && @host_context.respond_to?(method_name)
|
|
147
|
+
|
|
148
|
+
raise NoMethodError, "Unknown helper `#{method_name}` in jekyll_files file context. Available DSL methods: `frontmatter` and `contents`."
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Mirrors delegated method checks for introspection.
|
|
152
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
153
|
+
(@host_context && @host_context.respond_to?(method_name, include_private)) || super
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module JekyllTestHarness
|
|
7
|
+
# Loads fixture files relative to the configured project root for DSL helper APIs.
|
|
8
|
+
module FixtureLoader
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Reads a fixture file as raw text.
|
|
12
|
+
def read_text(file:, project_root:)
|
|
13
|
+
resolved_path = resolve_path(file: file, project_root: project_root)
|
|
14
|
+
File.read(resolved_path)
|
|
15
|
+
rescue Errno::ENOENT
|
|
16
|
+
raise ArgumentError, "Fixture file was not found: #{file.inspect}. Resolved path: #{resolved_path}. Project root: #{File.expand_path(project_root.to_s)}."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Reads and parses a YAML fixture, requiring the root document to be a hash.
|
|
20
|
+
def read_yaml_hash(file:, project_root:)
|
|
21
|
+
loaded_yaml = YAML.safe_load(read_text(file: file, project_root: project_root))
|
|
22
|
+
return {} if loaded_yaml.nil?
|
|
23
|
+
return loaded_yaml if loaded_yaml.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
raise ArgumentError, "Fixture '#{file}' must contain a YAML hash. Received #{ValidationMessages.describe_value(loaded_yaml)}."
|
|
26
|
+
rescue Psych::SyntaxError => raised_error
|
|
27
|
+
raise ArgumentError, "Fixture '#{file}' contains invalid YAML: #{raised_error.problem || raised_error.message}."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Resolves a fixture path relative to the project root and blocks path traversal.
|
|
31
|
+
def resolve_path(file:, project_root:)
|
|
32
|
+
fixture_path = normalise_fixture_path(file)
|
|
33
|
+
project_root_path = normalise_project_root(project_root)
|
|
34
|
+
resolved_path = File.expand_path(fixture_path, project_root_path)
|
|
35
|
+
return resolved_path if path_within_root?(resolved_path, project_root_path)
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, "Fixture path escapes the configured project root: #{fixture_path.inspect}. Project root: #{project_root_path}."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns true when the candidate path is inside the configured project root.
|
|
41
|
+
def path_within_root?(candidate_path, root_path)
|
|
42
|
+
normalised_candidate = normalise_path_for_comparison(candidate_path)
|
|
43
|
+
normalised_root = normalise_path_for_comparison(root_path)
|
|
44
|
+
normalised_candidate == normalised_root || normalised_candidate.start_with?("#{normalised_root}/")
|
|
45
|
+
end
|
|
46
|
+
private_class_method :path_within_root?
|
|
47
|
+
|
|
48
|
+
# Normalises path separators and applies Windows-only case-folding for path containment checks.
|
|
49
|
+
def normalise_path_for_comparison(path)
|
|
50
|
+
normalised_path = File.expand_path(path).tr('\\', '/')
|
|
51
|
+
return normalised_path.downcase if windows_platform?
|
|
52
|
+
|
|
53
|
+
normalised_path
|
|
54
|
+
end
|
|
55
|
+
private_class_method :normalise_path_for_comparison
|
|
56
|
+
|
|
57
|
+
# Returns true when running on a Windows platform with case-insensitive default semantics.
|
|
58
|
+
def windows_platform?
|
|
59
|
+
RbConfig::CONFIG['host_os'].to_s.match?(/mswin|mingw|cygwin/i)
|
|
60
|
+
end
|
|
61
|
+
private_class_method :windows_platform?
|
|
62
|
+
|
|
63
|
+
# Normalises and validates fixture path arguments.
|
|
64
|
+
def normalise_fixture_path(file)
|
|
65
|
+
fixture_path = if file.is_a?(String)
|
|
66
|
+
file
|
|
67
|
+
elsif file.respond_to?(:to_path)
|
|
68
|
+
file.to_path.to_s
|
|
69
|
+
else
|
|
70
|
+
raise ArgumentError, ValidationMessages.type_error(
|
|
71
|
+
argument_name: 'file',
|
|
72
|
+
expected: 'a String path or Pathname',
|
|
73
|
+
value: file,
|
|
74
|
+
usage: "Use a project-relative fixture path such as `file: 'spec/fixtures/dsl/config.yml'`."
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raise ArgumentError, "file must not be empty. Use a project-relative fixture path such as `file: 'spec/fixtures/dsl/config.yml'`." if fixture_path.strip.empty?
|
|
79
|
+
|
|
80
|
+
fixture_path
|
|
81
|
+
end
|
|
82
|
+
private_class_method :normalise_fixture_path
|
|
83
|
+
|
|
84
|
+
# Normalises and validates project root arguments.
|
|
85
|
+
def normalise_project_root(project_root)
|
|
86
|
+
root_path = if project_root.is_a?(String)
|
|
87
|
+
project_root
|
|
88
|
+
elsif project_root.respond_to?(:to_path)
|
|
89
|
+
project_root.to_path.to_s
|
|
90
|
+
else
|
|
91
|
+
raise ArgumentError, ValidationMessages.type_error(
|
|
92
|
+
argument_name: 'project_root',
|
|
93
|
+
expected: 'a String path or Pathname',
|
|
94
|
+
value: project_root,
|
|
95
|
+
usage: 'Pass the project root captured by `JekyllTestHarness.install!`.'
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
raise ArgumentError, 'project_root must not be empty.' if root_path.strip.empty?
|
|
99
|
+
|
|
100
|
+
File.expand_path(root_path)
|
|
101
|
+
end
|
|
102
|
+
private_class_method :normalise_project_root
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllTestHarness
|
|
4
|
+
# Represents reusable Jekyll build inputs that can be merged and composed in tests.
|
|
5
|
+
class JekyllBlueprint
|
|
6
|
+
attr_reader :config, :files
|
|
7
|
+
|
|
8
|
+
# Stores deep-cloned config and file hashes so blueprint instances stay immutable to callers.
|
|
9
|
+
def initialize(config: {}, files: {})
|
|
10
|
+
validate_hash!(config, 'config')
|
|
11
|
+
validate_hash!(files, 'files')
|
|
12
|
+
|
|
13
|
+
@config = DataTools.deep_clone(config)
|
|
14
|
+
@files = DataTools.deep_clone(files)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Ensures public blueprint inputs are always hash-like structures.
|
|
20
|
+
def validate_hash!(value, field_name)
|
|
21
|
+
return if value.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
raise ArgumentError, ValidationMessages.type_error(
|
|
24
|
+
argument_name: "JekyllBlueprint #{field_name}",
|
|
25
|
+
expected: 'a Hash',
|
|
26
|
+
value: value,
|
|
27
|
+
usage: 'Build blueprints with `jekyll_blueprint(config: { ... }, files: { ... })`.'
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllTestHarness
|
|
4
|
+
# Exposes output-first helpers for a built site while still supporting source inspection.
|
|
5
|
+
class Files
|
|
6
|
+
attr_reader :dir, :source_dir
|
|
7
|
+
|
|
8
|
+
# Initialises helpers with output and source roots.
|
|
9
|
+
def initialize(source_dir:, dir:)
|
|
10
|
+
@source_dir = source_dir
|
|
11
|
+
@dir = dir
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Returns the absolute path to a generated output file.
|
|
15
|
+
def path(relative_path)
|
|
16
|
+
resolve_relative_path(dir, relative_path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Reads a generated file from the output directory.
|
|
20
|
+
def read(relative_path)
|
|
21
|
+
File.read(path(relative_path))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Lists generated output files as paths relative to the output directory.
|
|
25
|
+
def list(root = nil)
|
|
26
|
+
list_relative_files(dir, root)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the absolute path to a source file in the temporary site.
|
|
30
|
+
def source_path(relative_path)
|
|
31
|
+
resolve_relative_path(source_dir, relative_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Reads a source file from the source directory.
|
|
35
|
+
def source_read(relative_path)
|
|
36
|
+
File.read(source_path(relative_path))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Lists source files as paths relative to the source directory.
|
|
40
|
+
def source_list(root = nil)
|
|
41
|
+
list_relative_files(source_dir, root)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Resolves a relative path and prevents directory traversal outside the root.
|
|
47
|
+
def resolve_relative_path(root_path, relative_path)
|
|
48
|
+
normalised_relative_path = normalise_relative_path(relative_path)
|
|
49
|
+
if absolute_path?(normalised_relative_path)
|
|
50
|
+
raise ArgumentError, "relative_path must not be absolute. Received #{ValidationMessages.describe_value(relative_path)}."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
resolved_path = File.expand_path(File.join(root_path, normalised_relative_path))
|
|
54
|
+
expanded_root = File.expand_path(root_path)
|
|
55
|
+
return resolved_path if resolved_path == expanded_root || resolved_path.start_with?("#{expanded_root}#{File::SEPARATOR}")
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "relative_path escapes the site root: #{normalised_relative_path.inspect}. Site root: #{expanded_root}."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Lists file paths for either the root or a subfolder, always returned relative to root_path.
|
|
61
|
+
def list_relative_files(root_path, list_root)
|
|
62
|
+
search_root = list_root.nil? ? root_path : resolve_relative_path(root_path, list_root)
|
|
63
|
+
return [] unless File.exist?(search_root)
|
|
64
|
+
|
|
65
|
+
paths = if File.file?(search_root)
|
|
66
|
+
[search_root]
|
|
67
|
+
else
|
|
68
|
+
Dir.glob(File.join(search_root, '**', '*')).select { |candidate_path| File.file?(candidate_path) }
|
|
69
|
+
end
|
|
70
|
+
paths.map { |absolute_path| absolute_path.sub("#{File.expand_path(root_path)}#{File::SEPARATOR}", '').tr('\\', '/') }.sort
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Normalises relative path values and rejects invalid types.
|
|
74
|
+
def normalise_relative_path(relative_path)
|
|
75
|
+
return relative_path if relative_path.is_a?(String)
|
|
76
|
+
return relative_path.to_path.to_s if relative_path.respond_to?(:to_path)
|
|
77
|
+
|
|
78
|
+
raise ArgumentError, ValidationMessages.type_error(
|
|
79
|
+
argument_name: 'relative_path',
|
|
80
|
+
expected: 'a String path or Pathname',
|
|
81
|
+
value: relative_path,
|
|
82
|
+
usage: "Use a path relative to the build root, such as `'docs/index.html'`."
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns true when a candidate path is absolute in Unix or Windows forms.
|
|
87
|
+
def absolute_path?(candidate_path)
|
|
88
|
+
candidate_path.start_with?('/') || candidate_path.match?(/\A[A-Za-z]:[\\\/]/)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Provides a backwards-compatible constant alias for older code paths.
|
|
93
|
+
Paths = Files
|
|
94
|
+
end
|