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