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.
- checksums.yaml +7 -0
- data/README.adoc +2915 -0
- data/bin/releasehx +7 -0
- data/bin/rhx +7 -0
- data/bin/rhx-mcp +7 -0
- data/bin/sourcerer +32 -0
- data/build/docs/CNAME +1 -0
- data/build/docs/Gemfile.lock +95 -0
- data/build/docs/_config.yml +36 -0
- data/build/docs/config-reference.adoc +4104 -0
- data/build/docs/config-reference.json +1546 -0
- data/build/docs/index.adoc +2915 -0
- data/build/docs/landing.adoc +21 -0
- data/build/docs/manpage.adoc +68 -0
- data/build/docs/releasehx.1 +281 -0
- data/build/docs/releasehx_readme.html +367 -0
- data/build/docs/sample-config.adoc +9 -0
- data/build/docs/sample-config.yml +251 -0
- data/build/docs/schemagraphy_readme.html +0 -0
- data/build/docs/sourcerer_readme.html +46 -0
- data/build/snippets/helpscreen.txt +29 -0
- data/lib/docopslab/mcp/asset_packager.rb +30 -0
- data/lib/docopslab/mcp/manifest.rb +67 -0
- data/lib/docopslab/mcp/resource_pack.rb +46 -0
- data/lib/docopslab/mcp/server.rb +92 -0
- data/lib/docopslab/mcp.rb +6 -0
- data/lib/releasehx/cli.rb +937 -0
- data/lib/releasehx/configuration.rb +215 -0
- data/lib/releasehx/generated.rb +17 -0
- data/lib/releasehx/helpers.rb +58 -0
- data/lib/releasehx/mcp/asset_packager.rb +21 -0
- data/lib/releasehx/mcp/assets/agent-config-guide.md +178 -0
- data/lib/releasehx/mcp/assets/config-def.yml +1426 -0
- data/lib/releasehx/mcp/assets/config-reference.adoc +4104 -0
- data/lib/releasehx/mcp/assets/config-reference.json +1546 -0
- data/lib/releasehx/mcp/assets/sample-config.yml +251 -0
- data/lib/releasehx/mcp/manifest.rb +18 -0
- data/lib/releasehx/mcp/resource_pack.rb +26 -0
- data/lib/releasehx/mcp/server.rb +57 -0
- data/lib/releasehx/mcp.rb +7 -0
- data/lib/releasehx/ops/check_ops.rb +136 -0
- data/lib/releasehx/ops/draft_ops.rb +173 -0
- data/lib/releasehx/ops/enrich_ops.rb +221 -0
- data/lib/releasehx/ops/template_ops.rb +61 -0
- data/lib/releasehx/ops/write_ops.rb +124 -0
- data/lib/releasehx/rest/clients/github.yml +46 -0
- data/lib/releasehx/rest/clients/gitlab.yml +31 -0
- data/lib/releasehx/rest/clients/jira.yml +31 -0
- data/lib/releasehx/rest/yaml_client.rb +418 -0
- data/lib/releasehx/rhyml/adapter.rb +740 -0
- data/lib/releasehx/rhyml/change.rb +167 -0
- data/lib/releasehx/rhyml/liquid.rb +13 -0
- data/lib/releasehx/rhyml/loaders.rb +37 -0
- data/lib/releasehx/rhyml/mappings/github.yaml +60 -0
- data/lib/releasehx/rhyml/mappings/gitlab.yaml +73 -0
- data/lib/releasehx/rhyml/mappings/jira.yaml +29 -0
- data/lib/releasehx/rhyml/mappings/verb_past_tenses.yml +98 -0
- data/lib/releasehx/rhyml/release.rb +144 -0
- data/lib/releasehx/rhyml.rb +15 -0
- data/lib/releasehx/sgyml/helpers.rb +45 -0
- data/lib/releasehx/transforms/adf_to_markdown.rb +307 -0
- data/lib/releasehx/version.rb +7 -0
- data/lib/releasehx.rb +69 -0
- data/lib/schemagraphy/attribute_resolver.rb +48 -0
- data/lib/schemagraphy/cfgyml/definition.rb +90 -0
- data/lib/schemagraphy/cfgyml/doc_builder.rb +52 -0
- data/lib/schemagraphy/cfgyml/path_reference.rb +24 -0
- data/lib/schemagraphy/data_query/json_pointer.rb +42 -0
- data/lib/schemagraphy/loader.rb +59 -0
- data/lib/schemagraphy/regexp_utils.rb +215 -0
- data/lib/schemagraphy/safe_expression.rb +189 -0
- data/lib/schemagraphy/schema_utils.rb +124 -0
- data/lib/schemagraphy/tag_utils.rb +32 -0
- data/lib/schemagraphy/templating.rb +104 -0
- data/lib/schemagraphy.rb +17 -0
- data/lib/sourcerer/builder.rb +120 -0
- data/lib/sourcerer/jekyll/bootstrapper.rb +78 -0
- data/lib/sourcerer/jekyll/liquid/file_system.rb +74 -0
- data/lib/sourcerer/jekyll/liquid/filters.rb +215 -0
- data/lib/sourcerer/jekyll/liquid/tags.rb +44 -0
- data/lib/sourcerer/jekyll/monkeypatches.rb +73 -0
- data/lib/sourcerer/jekyll.rb +26 -0
- data/lib/sourcerer/plaintext_converter.rb +75 -0
- data/lib/sourcerer/templating.rb +190 -0
- data/lib/sourcerer.rb +322 -0
- data/specs/data/api-client-schema.yaml +160 -0
- data/specs/data/config-def.yml +1426 -0
- data/specs/data/mcp-manifest.yml +50 -0
- data/specs/data/rhyml-mapping-schema.yaml +410 -0
- data/specs/data/rhyml-schema.yaml +152 -0
- 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
|