releasehx 0.1.0

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.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/README.adoc +2915 -0
  3. data/bin/releasehx +7 -0
  4. data/bin/rhx +7 -0
  5. data/bin/rhx-mcp +7 -0
  6. data/bin/sourcerer +32 -0
  7. data/build/docs/CNAME +1 -0
  8. data/build/docs/Gemfile.lock +95 -0
  9. data/build/docs/_config.yml +36 -0
  10. data/build/docs/config-reference.adoc +4104 -0
  11. data/build/docs/config-reference.json +1546 -0
  12. data/build/docs/index.adoc +2915 -0
  13. data/build/docs/landing.adoc +21 -0
  14. data/build/docs/manpage.adoc +68 -0
  15. data/build/docs/releasehx.1 +281 -0
  16. data/build/docs/releasehx_readme.html +367 -0
  17. data/build/docs/sample-config.adoc +9 -0
  18. data/build/docs/sample-config.yml +251 -0
  19. data/build/docs/schemagraphy_readme.html +0 -0
  20. data/build/docs/sourcerer_readme.html +46 -0
  21. data/build/snippets/helpscreen.txt +29 -0
  22. data/lib/docopslab/mcp/asset_packager.rb +30 -0
  23. data/lib/docopslab/mcp/manifest.rb +67 -0
  24. data/lib/docopslab/mcp/resource_pack.rb +46 -0
  25. data/lib/docopslab/mcp/server.rb +92 -0
  26. data/lib/docopslab/mcp.rb +6 -0
  27. data/lib/releasehx/cli.rb +937 -0
  28. data/lib/releasehx/configuration.rb +215 -0
  29. data/lib/releasehx/generated.rb +17 -0
  30. data/lib/releasehx/helpers.rb +58 -0
  31. data/lib/releasehx/mcp/asset_packager.rb +21 -0
  32. data/lib/releasehx/mcp/assets/agent-config-guide.md +178 -0
  33. data/lib/releasehx/mcp/assets/config-def.yml +1426 -0
  34. data/lib/releasehx/mcp/assets/config-reference.adoc +4104 -0
  35. data/lib/releasehx/mcp/assets/config-reference.json +1546 -0
  36. data/lib/releasehx/mcp/assets/sample-config.yml +251 -0
  37. data/lib/releasehx/mcp/manifest.rb +18 -0
  38. data/lib/releasehx/mcp/resource_pack.rb +26 -0
  39. data/lib/releasehx/mcp/server.rb +57 -0
  40. data/lib/releasehx/mcp.rb +7 -0
  41. data/lib/releasehx/ops/check_ops.rb +136 -0
  42. data/lib/releasehx/ops/draft_ops.rb +173 -0
  43. data/lib/releasehx/ops/enrich_ops.rb +221 -0
  44. data/lib/releasehx/ops/template_ops.rb +61 -0
  45. data/lib/releasehx/ops/write_ops.rb +124 -0
  46. data/lib/releasehx/rest/clients/github.yml +46 -0
  47. data/lib/releasehx/rest/clients/gitlab.yml +31 -0
  48. data/lib/releasehx/rest/clients/jira.yml +31 -0
  49. data/lib/releasehx/rest/yaml_client.rb +418 -0
  50. data/lib/releasehx/rhyml/adapter.rb +740 -0
  51. data/lib/releasehx/rhyml/change.rb +167 -0
  52. data/lib/releasehx/rhyml/liquid.rb +13 -0
  53. data/lib/releasehx/rhyml/loaders.rb +37 -0
  54. data/lib/releasehx/rhyml/mappings/github.yaml +60 -0
  55. data/lib/releasehx/rhyml/mappings/gitlab.yaml +73 -0
  56. data/lib/releasehx/rhyml/mappings/jira.yaml +29 -0
  57. data/lib/releasehx/rhyml/mappings/verb_past_tenses.yml +98 -0
  58. data/lib/releasehx/rhyml/release.rb +144 -0
  59. data/lib/releasehx/rhyml.rb +15 -0
  60. data/lib/releasehx/sgyml/helpers.rb +45 -0
  61. data/lib/releasehx/transforms/adf_to_markdown.rb +307 -0
  62. data/lib/releasehx/version.rb +7 -0
  63. data/lib/releasehx.rb +69 -0
  64. data/lib/schemagraphy/attribute_resolver.rb +48 -0
  65. data/lib/schemagraphy/cfgyml/definition.rb +90 -0
  66. data/lib/schemagraphy/cfgyml/doc_builder.rb +52 -0
  67. data/lib/schemagraphy/cfgyml/path_reference.rb +24 -0
  68. data/lib/schemagraphy/data_query/json_pointer.rb +42 -0
  69. data/lib/schemagraphy/loader.rb +59 -0
  70. data/lib/schemagraphy/regexp_utils.rb +215 -0
  71. data/lib/schemagraphy/safe_expression.rb +189 -0
  72. data/lib/schemagraphy/schema_utils.rb +124 -0
  73. data/lib/schemagraphy/tag_utils.rb +32 -0
  74. data/lib/schemagraphy/templating.rb +104 -0
  75. data/lib/schemagraphy.rb +17 -0
  76. data/lib/sourcerer/builder.rb +120 -0
  77. data/lib/sourcerer/jekyll/bootstrapper.rb +78 -0
  78. data/lib/sourcerer/jekyll/liquid/file_system.rb +74 -0
  79. data/lib/sourcerer/jekyll/liquid/filters.rb +215 -0
  80. data/lib/sourcerer/jekyll/liquid/tags.rb +44 -0
  81. data/lib/sourcerer/jekyll/monkeypatches.rb +73 -0
  82. data/lib/sourcerer/jekyll.rb +26 -0
  83. data/lib/sourcerer/plaintext_converter.rb +75 -0
  84. data/lib/sourcerer/templating.rb +190 -0
  85. data/lib/sourcerer.rb +322 -0
  86. data/specs/data/api-client-schema.yaml +160 -0
  87. data/specs/data/config-def.yml +1426 -0
  88. data/specs/data/mcp-manifest.yml +50 -0
  89. data/specs/data/rhyml-mapping-schema.yaml +410 -0
  90. data/specs/data/rhyml-schema.yaml +152 -0
  91. metadata +376 -0
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourcerer
4
+ module Jekyll
5
+ # This module contains monkeypatches for Jekyll to modify or extend its behavior.
6
+ module Monkeypatches
7
+ # Patches Jekyll's `OptimizedIncludeTag` to modify its behavior.
8
+ # The patch enhances include path resolution and context handling to better
9
+ # suit the needs of Sourcerer's templating environment.
10
+ def self.patch_jekyll
11
+ return unless defined?(::Jekyll::Tags::OptimizedIncludeTag)
12
+
13
+ ::Jekyll::Tags::OptimizedIncludeTag.class_eval do
14
+ define_method :render do |context|
15
+ site = context.registers[:site]
16
+ file = render_variable(context) || @file
17
+
18
+ context.stack do
19
+ context['include'] = parse_params(context) if @params
20
+
21
+ source = site.inclusions[file]
22
+
23
+ unless source
24
+
25
+ # Debug lines before attempting path resolution
26
+
27
+ # Safe resolution
28
+ paths = context.registers[:includes_load_paths] || []
29
+ path = paths
30
+ .map { |dir| File.join(dir, file) }
31
+ .find { |p| File.file?(p) }
32
+
33
+ raise IOError, "Include file not found: #{file}" unless path
34
+
35
+ source = File.read(path)
36
+ end
37
+
38
+ partial = ::Liquid::Template.parse(source)
39
+ partial.registers[:site] = context.registers[:site]
40
+ partial.assigns['include'] = context['include']
41
+
42
+ ::Liquid::Template.register_filter(::Jekyll::Filters)
43
+ ::Liquid::Template.register_filter(::Sourcerer::Jekyll::Liquid::Filters)
44
+
45
+ # Use an isolated context so we can inspect and copy assigns
46
+ subcontext = ::Liquid::Context.new(
47
+ [{ 'include' => context['include'] }],
48
+ {}, # Environments
49
+ context.registers,
50
+ rethrow_errors: true)
51
+
52
+ rendered = partial.render!(subcontext)
53
+
54
+ # Copy assigns from subcontext to parent context
55
+ subcontext.environments.each do |env|
56
+ env.each do |k, v|
57
+ # Avoid clobbering outer include if reentrant
58
+ next if k == 'include'
59
+
60
+ context.environments.first[k] = v
61
+ end
62
+ end
63
+
64
+ rendered
65
+ end
66
+ end
67
+ end
68
+
69
+ ::Liquid::Template.tags['include'] = ::Jekyll::Tags::OptimizedIncludeTag
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'jekyll/bootstrapper'
4
+ require_relative 'jekyll/monkeypatches'
5
+ require_relative 'jekyll/liquid/file_system'
6
+ require_relative 'jekyll/liquid/filters'
7
+ require_relative 'jekyll/liquid/tags'
8
+ require 'jekyll-asciidoc'
9
+
10
+ module Sourcerer
11
+ # This module encapsulates the logic for initializing a Jekyll-like Liquid
12
+ # templating environment. It loads necessary plugins, applies monkeypatches,
13
+ # and registers custom Liquid filters and tags.
14
+ module Jekyll
15
+ # Initializes the Liquid templating runtime by loading plugins,
16
+ # applying patches, and registering custom filters.
17
+ def self.initialize_liquid_runtime
18
+ Bootstrapper.load_plugins
19
+ Monkeypatches.patch_jekyll
20
+ # Ensure Sourcerer filters are registered
21
+ ::Liquid::Template.register_filter(::Sourcerer::Jekyll::Liquid::Filters)
22
+ # Ensure jekyll-asciidoc filters are registered
23
+ # ::Liquid::Template.register_filter(Jekyll::AsciiDoc::Filters)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,75 @@
1
+ # This module will likely spin off into a gem
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require 'asciidoctor'
6
+
7
+ module Sourcerer
8
+ # A custom Asciidoctor converter that outputs plain text.
9
+ # It is registered for the "plaintext" backend and can be used to extract
10
+ # the raw text content or attributes from an AsciiDoc document.
11
+ class PlainTextConverter < Asciidoctor::Converter::Base
12
+ # Identify ourselves as a converter for the "plaintext" backend
13
+ register_for 'plaintext'
14
+
15
+ # The main entry point for the converter.
16
+ # It is called by Asciidoctor to convert a node.
17
+ #
18
+ # @param node [Asciidoctor::AbstractNode] The node to convert.
19
+ # @param _transform [String] The transform to apply (unused).
20
+ # @param _opts [Hash] Options for the conversion (unused).
21
+ # @return [String] The converted plain text output.
22
+ def convert node, _transform = nil, _opts = {}
23
+ if respond_to?("convert_#{node.node_name}", true)
24
+ send("convert_#{node.node_name}", node)
25
+ elsif node.respond_to?(:content)
26
+ node.content.to_s
27
+ elsif node.respond_to?(:text)
28
+ node.text.to_s
29
+ else
30
+ ''
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Converts the document node.
37
+ def convert_document node
38
+ emit_attrs = node.attr('sourcerer_mode') == 'emit_attrs'
39
+
40
+ if emit_attrs
41
+ # only emit attribute lines directly, nothing else
42
+ attrs = node.attributes.select do |k, v|
43
+ k.is_a?(String) && !v.nil? && !k.start_with?('backend-', 'safe-mode', 'doctype', 'sourcerer_mode')
44
+ end
45
+
46
+ formatted_attrs = attrs.map { |k, v| ":#{k}: #{v}" }
47
+ formatted_attrs.join("\n") # NO EXTRA SPACES OR LINES
48
+ else
49
+ node.blocks.map { |block| convert block }.join("\n")
50
+ end
51
+ end
52
+
53
+ # Converts a section node.
54
+ def convert_section node
55
+ title = node.title? ? node.title : ''
56
+ body = node.blocks.map { |block| convert block }.join("\n")
57
+ [title, body].reject(&:empty?).join("\n")
58
+ end
59
+
60
+ # Converts a paragraph node.
61
+ def convert_paragraph node
62
+ node.lines.join("\n")
63
+ end
64
+
65
+ # Converts a listing node.
66
+ def convert_listing node
67
+ node.content
68
+ end
69
+
70
+ # Converts a literal node.
71
+ def convert_literal node
72
+ node.content
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'liquid'
4
+
5
+ module Sourcerer
6
+ # This module provides the core templating functionality for Sourcerer.
7
+ # It includes modules for template engines, and classes for representing
8
+ # templated fields and their context.
9
+ module Templating
10
+ # This module handles the compilation and rendering of templates.
11
+ module Engines
12
+ module_function
13
+
14
+ # A hash of supported template engines.
15
+ SUPPORTED_ENGINES = {
16
+ 'liquid' => 'liquid',
17
+ 'erb' => 'erb'
18
+ }.freeze
19
+
20
+ # Compiles a template string using the specified engine.
21
+ #
22
+ # @param str [String] The template string to compile.
23
+ # @param engine [String] The name of the template engine to use.
24
+ # @return [Object] The compiled template object.
25
+ # @raise [ArgumentError] if the engine is not supported.
26
+ def compile str, engine
27
+ case engine.to_s
28
+ when 'liquid'
29
+ Liquid::Template.parse(str)
30
+ when 'erb'
31
+ require 'erb'
32
+ ERB.new(str)
33
+ else
34
+ raise ArgumentError, "Unsupported engine: #{engine}"
35
+ end
36
+ end
37
+
38
+ # Renders a compiled template with the given variables.
39
+ #
40
+ # @param compiled [Object] The compiled template object.
41
+ # @param engine [String] The name of the template engine.
42
+ # @param vars [Hash] A hash of variables to use for rendering.
43
+ # @return [String] The rendered output.
44
+ def render compiled, engine, vars = {}
45
+ case engine.to_s
46
+ when 'liquid'
47
+ compiled.render(vars)
48
+ when 'erb'
49
+ compiled.result_with_hash(vars)
50
+ else
51
+ compiled.to_s
52
+ end
53
+ end
54
+ end
55
+
56
+ # Represents a field that will be rendered by a template engine.
57
+ class TemplatedField
58
+ # @return [String] The raw, un-rendered template string.
59
+ attr_reader :raw
60
+ # @return [Object] The compiled template object.
61
+ attr_reader :compiled
62
+ # @return [String] The name of the template engine.
63
+ attr_reader :engine
64
+ # @return [Boolean] Whether the template was explicitly tagged.
65
+ attr_reader :tagged
66
+ # @return [Boolean] Whether the template engine was inferred.
67
+ attr_reader :inferred
68
+
69
+ # @param raw [String] The raw template string.
70
+ # @param compiled [Object] The compiled template object.
71
+ # @param engine [String] The name of the template engine.
72
+ # @param tagged [Boolean] Whether the template was explicitly tagged.
73
+ # @param inferred [Boolean] Whether the template engine was inferred.
74
+ def initialize raw, compiled, engine, tagged, inferred
75
+ @raw = raw
76
+ @compiled = compiled
77
+ @engine = engine
78
+ @tagged = tagged
79
+ @inferred = inferred
80
+ end
81
+
82
+ # @return [true] Always returns true to indicate this is a templated field.
83
+ def templated?
84
+ true
85
+ end
86
+
87
+ # @return [Boolean] True if the field is deferred (not yet compiled).
88
+ def deferred?
89
+ compiled.nil?
90
+ end
91
+
92
+ # @return [self] Returns self for Liquid compatibility.
93
+ def to_liquid
94
+ self
95
+ end
96
+
97
+ # Renders the template with the given context.
98
+ # @param context [Hash, Liquid::Context] The context for rendering.
99
+ # @return [String] The rendered output.
100
+ def render context = {}
101
+ scope = context.respond_to?(:environments) ? context.environments.first : context
102
+ Engines.render(compiled, engine, scope)
103
+ end
104
+
105
+ # Renders the template with an empty context.
106
+ # @return [String] The rendered output.
107
+ def to_s
108
+ render({})
109
+ end
110
+ end
111
+
112
+ # Holds contextual information for templating.
113
+ class Context
114
+ # @return [Symbol] The rendering stage (e.g., `:load`).
115
+ attr_reader :stage
116
+ # @return [Boolean] Whether to use strict rendering.
117
+ attr_reader :strict
118
+ # @return [Hash] A hash of scopes for rendering.
119
+ attr_reader :scopes
120
+
121
+ # @param stage [Symbol] The rendering stage.
122
+ # @param strict [Boolean] Whether to use strict rendering.
123
+ # @param scopes [Hash] A hash of scopes.
124
+ def initialize stage: :load, strict: false, scopes: {}
125
+ @stage = stage.to_sym
126
+ @strict = strict
127
+ @scopes = scopes.transform_keys(&:to_sym)
128
+ end
129
+
130
+ # Creates a new Context object from a schema fragment.
131
+ # @param schema_fragment [Hash] The schema fragment containing templating info.
132
+ # @return [Context] The new Context object.
133
+ def self.from_schema schema_fragment
134
+ render_conf = schema_fragment['templating'] || {}
135
+
136
+ stage = (render_conf['stage'] || :load).to_sym
137
+ strict = render_conf['strict'] == true
138
+ scopes = (render_conf['scopes'] || {}).transform_keys(&:to_sym)
139
+
140
+ new(stage: stage, strict: strict, scopes: scopes)
141
+ end
142
+
143
+ # Merges all scopes into a single hash.
144
+ # @return [Hash] The merged scope.
145
+ def merged_scope
146
+ scopes.values.reduce({}) { |acc, s| acc.merge(s) }
147
+ end
148
+ end
149
+
150
+ # Compiles templated fields in a data structure.
151
+ # @param data [Hash] The data to process.
152
+ # @param schema [Hash] The schema defining the fields.
153
+ # @param fields [Array<Hash>] The fields to compile.
154
+ # @param scope [Hash] The scope for rendering.
155
+ def self.compile_templated_fields! data:, schema:, fields:, scope: {}
156
+ fields.each do |field_entry|
157
+ key = field_entry[:key]
158
+ path = field_entry[:path]
159
+ val = data[key]
160
+
161
+ next unless val.is_a?(String) || (val.is_a?(Hash) && val['__tag__'] && val['value'])
162
+
163
+ raw = val.is_a?(Hash) ? val['value'] : val
164
+ tagged = val.is_a?(Hash)
165
+ config = SchemaGraphy::SchemaUtils.templating_config_for(schema, path)
166
+ engine = tagged ? val['__tag__'] : (config['default'] || 'liquid')
167
+
168
+ compiled = Engines.compile(raw, engine)
169
+
170
+ data[key] = if config['delay']
171
+ TemplatedField.new(raw, compiled, engine, tagged, inferred: !tagged)
172
+ else
173
+ Engines.render(compiled, engine, scope)
174
+ end
175
+ end
176
+ end
177
+
178
+ # Renders a field if it is a template.
179
+ # @param val [Object] The value to render.
180
+ # @param context [Hash] The context for rendering.
181
+ # @return [Object] The rendered value, or the original value if not a template.
182
+ def self.render_field_if_template val, context = {}
183
+ if val.respond_to?(:templated?) && val.templated?
184
+ val.render(context)
185
+ else
186
+ val
187
+ end
188
+ end
189
+ end
190
+ end
data/lib/sourcerer.rb ADDED
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module is a pre-alpha version of what I will eventually spin off
4
+ # as AsciiSourcery, for single-sourcing documentation AND product data
5
+ # in AsciiDoc and YAML files
6
+ # It is pretty messy for now as I play around with various ways it might
7
+ # get used, including as a build-time generator of artifacts to be used
8
+ # in both the app and the docs
9
+
10
+ require 'asciidoctor'
11
+ require 'fileutils'
12
+ require 'yaml'
13
+ require_relative 'sourcerer/builder'
14
+ require_relative 'sourcerer/plaintext_converter'
15
+ require_relative 'sourcerer/templating'
16
+ require_relative 'sourcerer/jekyll'
17
+ require_relative 'schemagraphy'
18
+
19
+ # A tool for single-sourcing documentation and data from AsciiDoc and YAML files.
20
+ # It provides methods for extracting data, rendering templates, and generating various outputs.
21
+ module Sourcerer
22
+ # Loads AsciiDoc attributes from a document header as a Hash.
23
+ #
24
+ # @param path [String] The path to the AsciiDoc file.
25
+ # @return [Hash] A hash of the document attributes.
26
+ def self.load_attributes path
27
+ doc = Asciidoctor.load_file(path, safe: :unsafe)
28
+ doc.attributes
29
+ end
30
+
31
+ # Loads a snippet from an AsciiDoc file using an `include::` directive.
32
+ #
33
+ # @param path_to_main_adoc [String] The path to the main AsciiDoc file.
34
+ # @param tag [String] A single tag to include.
35
+ # @param tags [Array<String>] An array of tags to include.
36
+ # @param leveloffset [Integer] The level offset for the include.
37
+ # @return [String] The content of the included snippet.
38
+ def self.load_include path_to_main_adoc, tag: nil, tags: [], leveloffset: nil
39
+ opts = []
40
+ opts << "tag=#{tag}" if tag
41
+ opts << "tags=#{tags.join(',')}" if tags.any?
42
+ opts << "leveloffset=#{leveloffset}" if leveloffset
43
+
44
+ snippet_doc = <<~ADOC
45
+ include::#{path_to_main_adoc}[#{opts.join(', ')}]
46
+ ADOC
47
+
48
+ doc = Asciidoctor.load(
49
+ snippet_doc,
50
+ safe: :unsafe,
51
+ base_dir: File.expand_path('.'),
52
+ header_footer: false,
53
+ attributes: { 'source-highlighter' => nil }) # disable extras
54
+
55
+ # Get raw text from all top-level blocks
56
+ doc.blocks.map(&:content).join("\n")
57
+ end
58
+
59
+ # Extracts tagged content from a file.
60
+ #
61
+ # @param path_to_tagged_adoc [String] The path to the file with tagged content.
62
+ # @param tag [String] A single tag to extract.
63
+ # @param tags [Array<String>] An array of tags to extract.
64
+ # @param comment_prefix [String] The prefix for comment lines.
65
+ # @param comment_suffix [String] The suffix for comment lines.
66
+ # @param skip_comments [Boolean] Whether to skip comment lines in the output.
67
+ # @return [String] The extracted content.
68
+ # rubocop:disable Lint/UnusedMethodArgument
69
+ def self.extract_tagged_content path_to_tagged_adoc, tag: nil, tags: [], comment_prefix: '// ', comment_suffix: '',
70
+ skip_comments: false
71
+ # rubocop:enable Lint/UnusedMethodArgument
72
+ # NOTE: comment_suffix parameter is currently unused but kept for future functionality
73
+ raise ArgumentError, 'tag and tags cannot coexist' if tag && !tags.empty?
74
+
75
+ tags = [tag] if tag
76
+ raise ArgumentError, 'at least one tag must be specified' if tags.empty?
77
+ raise ArgumentError, 'tags must all be strings' unless tags.is_a?(Array) && tags.all? { |t| t.is_a?(String) }
78
+
79
+ tagged_content = []
80
+ open_tags = {}
81
+ tag_comment_prefix = comment_prefix.strip || '//'
82
+ tag_pattern = /^#{Regexp.escape(tag_comment_prefix)}\s*tag::([\w-]+)\[\]/
83
+ end_pattern = /^#{Regexp.escape(tag_comment_prefix)}\s*end::([\w-]+)\[\]/
84
+ comment_line_init_pattern = /^#{Regexp.escape(tag_comment_prefix)}+/
85
+ collecting = false
86
+ File.open(path_to_tagged_adoc, 'r') do |file|
87
+ file.each_line do |line|
88
+ # check for tag:: line
89
+ if line =~ tag_pattern
90
+ tag_name = Regexp.last_match(1)
91
+ if tags.include?(tag_name)
92
+ collecting = true
93
+ open_tags[tag_name] = true
94
+ end
95
+ elsif line =~ end_pattern
96
+ tag_name = Regexp.last_match(1)
97
+ if open_tags[tag_name]
98
+ open_tags.delete(tag_name)
99
+ collecting = false if open_tags.empty?
100
+ end
101
+ elsif collecting
102
+ tagged_content << line unless skip_comments && line =~ comment_line_init_pattern
103
+ end
104
+ end
105
+ tagged_content = if tagged_content.empty?
106
+ ''
107
+ else
108
+ # return a string of concatenated lines
109
+ tagged_content.join
110
+ end
111
+ end
112
+
113
+ tagged_content
114
+ end
115
+
116
+ # Generates a manpage from an AsciiDoc source file.
117
+ #
118
+ # @param source_adoc [String] The path to the source AsciiDoc file.
119
+ # @param target_manpage [String] The path to the target manpage file.
120
+ def self.generate_manpage source_adoc, target_manpage
121
+ FileUtils.mkdir_p File.dirname(target_manpage)
122
+ Asciidoctor.convert_file(
123
+ source_adoc,
124
+ backend: 'manpage',
125
+ safe: :unsafe,
126
+ standalone: true,
127
+ to_file: target_manpage)
128
+ end
129
+
130
+ # Renders a set of templates based on a configuration.
131
+ #
132
+ # @param templates_config [Array<Hash>] An array of template configurations.
133
+ def self.render_templates templates_config
134
+ render_outputs(templates_config)
135
+ end
136
+
137
+ # Renders templates or converter outputs based on a configuration.
138
+ #
139
+ # @param render_config [Array<Hash>] A list of render configurations.
140
+ def self.render_outputs render_config
141
+ return if render_config.nil? || render_config.empty?
142
+
143
+ render_config.each do |render_entry|
144
+ if render_entry[:converter]
145
+ render_with_converter(render_entry)
146
+ next
147
+ end
148
+
149
+ data_obj = render_entry[:key] || 'data'
150
+ attrs_source = render_entry[:attrs]
151
+ engine = render_entry[:engine] || 'liquid'
152
+
153
+ render_template(
154
+ render_entry[:template],
155
+ render_entry[:data],
156
+ render_entry[:out],
157
+ data_object: data_obj,
158
+ attrs_source: attrs_source,
159
+ engine: engine)
160
+ end
161
+ end
162
+
163
+ # Renders a single template with data.
164
+ #
165
+ # @param template_file [String] The path to the template file.
166
+ # @param data_file [String] The path to the data file (YAML).
167
+ # @param out_file [String] The path to the output file.
168
+ # @param data_object [String] The name of the data object in the template.
169
+ # @param includes_load_paths [Array<String>] Paths for Liquid includes.
170
+ # @param attrs_source [String] The path to an AsciiDoc file for attributes.
171
+ # @param engine [String] The template engine to use.
172
+ def self.render_template template_file, data_file, out_file, data_object: 'data', includes_load_paths: [],
173
+ attrs_source: nil, engine: 'liquid'
174
+ data = load_render_data(data_file, attrs_source)
175
+ out_file = File.expand_path(out_file)
176
+ FileUtils.mkdir_p(File.dirname(out_file))
177
+
178
+ template_path = File.expand_path(template_file)
179
+ template_content = File.read(template_path)
180
+
181
+ # Prepare context
182
+ context = {
183
+ data_object => data,
184
+ 'include' => { data_object => data } # for compatibility with {% include ... %} expecting include.var
185
+ }
186
+
187
+ rendered = case engine.to_s
188
+ when 'erb' then render_erb(template_content, context)
189
+ when 'liquid' then render_liquid(template_file, template_content, context, includes_load_paths)
190
+ else raise ArgumentError, "Unsupported template engine: #{engine}"
191
+ end
192
+
193
+ File.write(out_file, rendered)
194
+ end
195
+
196
+ def self.render_with_converter render_entry
197
+ data_file = render_entry[:data]
198
+ out_file = render_entry[:out]
199
+ raise ArgumentError, 'render entry missing :data' unless data_file
200
+ raise ArgumentError, 'render entry missing :out' unless out_file
201
+
202
+ data = load_render_data(data_file, render_entry[:attrs])
203
+ converter = resolve_converter(render_entry[:converter])
204
+ rendered = converter.call(data, render_entry)
205
+ raise ArgumentError, 'converter returned non-string output' unless rendered.is_a?(String)
206
+
207
+ out_file = File.expand_path(out_file)
208
+ FileUtils.mkdir_p(File.dirname(out_file))
209
+ File.write(out_file, rendered)
210
+ end
211
+
212
+ def self.load_render_data data_file, attrs_source
213
+ if attrs_source
214
+ attrs = load_attributes(attrs_source)
215
+ SchemaGraphy::Loader.load_yaml_with_attributes(data_file, attrs)
216
+ else
217
+ SchemaGraphy::Loader.load_yaml_with_tags(data_file)
218
+ end
219
+ end
220
+
221
+ def self.resolve_converter converter
222
+ return converter if converter.respond_to?(:call)
223
+ return Object.const_get(converter) if converter.is_a?(String)
224
+
225
+ raise ArgumentError, "Unsupported converter: #{converter.inspect}"
226
+ end
227
+
228
+ def self.render_erb template_content, context
229
+ require 'erb'
230
+ ERB.new(template_content, trim_mode: '-').result_with_hash(context)
231
+ end
232
+
233
+ def self.render_liquid template_file, template_content, context, includes_load_paths
234
+ require_relative 'sourcerer/jekyll'
235
+ require_relative 'sourcerer/jekyll/liquid/filters'
236
+ require_relative 'sourcerer/jekyll/liquid/tags'
237
+ require 'liquid' unless defined?(Liquid::Template)
238
+ Sourcerer::Jekyll.initialize_liquid_runtime
239
+
240
+ # Determine includes root; add template directory to search paths
241
+ fallback_templates_dir = File.expand_path('.', Dir.pwd)
242
+ template_dir = File.dirname(File.expand_path(template_file))
243
+ # For templates that use includes like cfgyml/config-property.adoc.liquid,
244
+ # we need the parent directory of the template's directory as well
245
+ template_parent_dir = File.dirname(template_dir)
246
+
247
+ paths = if includes_load_paths.any?
248
+ includes_load_paths
249
+ else
250
+ [template_parent_dir, template_dir, fallback_templates_dir]
251
+ end
252
+
253
+ # Create a fake Jekyll site
254
+ site = Sourcerer::Jekyll::Bootstrapper.fake_site(
255
+ includes_load_paths: paths,
256
+ plugin_dirs: [])
257
+
258
+ # Setup file system for includes with multiple paths
259
+ file_system = Sourcerer::Jekyll::Liquid::FileSystem.new(paths)
260
+
261
+ template = Liquid::Template.parse(template_content)
262
+ options = {
263
+ registers: {
264
+ site: site,
265
+ file_system: file_system
266
+ }
267
+ }
268
+ template.render(context, options)
269
+ end
270
+
271
+ # Extracts commands from listing and literal blocks with a specific role.
272
+ #
273
+ # @param file_path [String] The path to the AsciiDoc file.
274
+ # @param role [String] The role to look for.
275
+ # @return [Array<String>] An array of command groups.
276
+ def self.extract_commands file_path, role: 'testable'
277
+ doc = Asciidoctor.load_file(file_path, safe: :unsafe)
278
+ command_groups = []
279
+ current_group = []
280
+
281
+ blocks = doc.find_by(context: :listing) + doc.find_by(context: :literal)
282
+
283
+ blocks.each do |block|
284
+ next unless block.has_role?(role)
285
+
286
+ commands = process_block_content(block.content)
287
+ if block.has_role?('testable-newshell')
288
+ command_groups << current_group.join("\n") unless current_group.empty?
289
+ command_groups << commands.join("\n") unless commands.empty?
290
+ current_group = []
291
+ else
292
+ current_group.concat(commands)
293
+ end
294
+ end
295
+
296
+ command_groups << current_group.join("\n") unless current_group.empty?
297
+ command_groups
298
+ end
299
+
300
+ # @api private
301
+ # Processes the content of a block to extract commands.
302
+ # It handles line continuations and skips comments.
303
+ # @param content [String] The content of the block.
304
+ # @return [Array<String>] An array of commands.
305
+ def self.process_block_content content
306
+ processed_commands = []
307
+ current_command = ''
308
+ content.each_line do |line|
309
+ stripped_line = line.strip
310
+ next if stripped_line.start_with?('#') # Skip comments
311
+
312
+ if stripped_line.end_with?('\\')
313
+ current_command += "#{stripped_line.chomp('\\')} "
314
+ else
315
+ current_command += stripped_line
316
+ processed_commands << current_command unless current_command.empty?
317
+ current_command = ''
318
+ end
319
+ end
320
+ processed_commands
321
+ end
322
+ end