Module: Sourcerer

Defined in:
lib/sourcerer.rb,
lib/sourcerer/builder.rb,
lib/sourcerer/jekyll/bootstrapper.rb,
lib/sourcerer/jekyll/liquid/file_system.rb,
lib/sourcerer/jekyll/liquid/filters.rb,
lib/sourcerer/jekyll/liquid/tags.rb,
lib/sourcerer/jekyll/monkeypatches.rb,
lib/sourcerer/jekyll.rb,
lib/sourcerer/plaintext_converter.rb,
lib/sourcerer/templating.rb

Overview

A build-time code generator that creates assets such as new data, documentation, and even Ruby files from data extracted from AsciiDoc files, such as attributes and tagged regions.

Defined Under Namespace

Modules: Builder, Jekyll, Templating Classes: PlainTextConverter

Class Method Summary collapse

Class Method Details

.extract_commands(file_path, role: 'testable') ⇒ Array<String>

Extracts commands from listing and literal blocks with a specific role.

Parameters:

  • file_path (String)
    The path to the AsciiDoc file.
  • role (String) (defaults to: 'testable')
    The role to look for.

Returns:

  • (Array<String>)
    An array of command groups.


276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/sourcerer.rb', line 276

def self.extract_commands file_path, role: 'testable'
  doc = Asciidoctor.load_file(file_path, safe: :unsafe)
  command_groups = []
  current_group = []

  blocks = doc.find_by(context: :listing) + doc.find_by(context: :literal)

  blocks.each do |block|
    next unless block.has_role?(role)

    commands = process_block_content(block.content)
    if block.has_role?('testable-newshell')
      command_groups << current_group.join("\n") unless current_group.empty?
      command_groups << commands.join("\n") unless commands.empty?
      current_group = []
    else
      current_group.concat(commands)
    end
  end

  command_groups << current_group.join("\n") unless current_group.empty?
  command_groups
end

.extract_tagged_content(path_to_tagged_adoc, tag: nil, tags: [], comment_prefix: '// ', comment_suffix: '', skip_comments: false) ⇒ String

Extracts tagged content from a file. rubocop:disable Lint/UnusedMethodArgument

Parameters:

  • path_to_tagged_adoc (String)
    The path to the file with tagged content.
  • tag (String) (defaults to: nil)
    A single tag to extract.
  • tags (Array<String>) (defaults to: [])
    An array of tags to extract.
  • comment_prefix (String) (defaults to: '// ')
    The prefix for comment lines.
  • comment_suffix (String) (defaults to: '')
    The suffix for comment lines.
  • skip_comments (Boolean) (defaults to: false)
    Whether to skip comment lines in the output.

Returns:

  • (String)
    The extracted content.

Raises:

  • (ArgumentError)


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/sourcerer.rb', line 69

def self.extract_tagged_content path_to_tagged_adoc, tag: nil, tags: [], comment_prefix: '// ', comment_suffix: '',
  skip_comments: false
  # rubocop:enable Lint/UnusedMethodArgument
  # NOTE: comment_suffix parameter is currently unused but kept for future functionality
  raise ArgumentError, 'tag and tags cannot coexist' if tag && !tags.empty?

  tags = [tag] if tag
  raise ArgumentError, 'at least one tag must be specified' if tags.empty?
  raise ArgumentError, 'tags must all be strings' unless tags.is_a?(Array) && tags.all? { |t| t.is_a?(String) }

  tagged_content = []
  open_tags = {}
  tag_comment_prefix = comment_prefix.strip || '//'
  tag_pattern = /^#{Regexp.escape(tag_comment_prefix)}\s*tag::([\w-]+)\[\]/
  end_pattern = /^#{Regexp.escape(tag_comment_prefix)}\s*end::([\w-]+)\[\]/
  comment_line_init_pattern = /^#{Regexp.escape(tag_comment_prefix)}+/
  collecting = false
  File.open(path_to_tagged_adoc, 'r') do |file|
    file.each_line do |line|
      # check for tag:: line
      if line =~ tag_pattern
        tag_name = Regexp.last_match(1)
        if tags.include?(tag_name)
          collecting = true
          open_tags[tag_name] = true
        end
      elsif line =~ end_pattern
        tag_name = Regexp.last_match(1)
        if open_tags[tag_name]
          open_tags.delete(tag_name)
          collecting = false if open_tags.empty?
        end
      elsif collecting
        tagged_content << line unless skip_comments && line =~ comment_line_init_pattern
      end
    end
    tagged_content = if tagged_content.empty?
                       ''
                     else
                       # return a string of concatenated lines
                       tagged_content.join
                     end
  end

  tagged_content
end

.generate_manpage(source_adoc, target_manpage) ⇒ Object

Generates a manpage from an AsciiDoc source file.

Parameters:

  • source_adoc (String)
    The path to the source AsciiDoc file.
  • target_manpage (String)
    The path to the target manpage file.


120
121
122
123
124
125
126
127
128
# File 'lib/sourcerer.rb', line 120

def self.generate_manpage source_adoc, target_manpage
  FileUtils.mkdir_p File.dirname(target_manpage)
  Asciidoctor.convert_file(
    source_adoc,
    backend: 'manpage',
    safe: :unsafe,
    standalone: true,
    to_file: target_manpage)
end

.load_attributes(path) ⇒ Hash

Loads AsciiDoc attributes from a document header as a Hash.

Parameters:

  • path (String)
    The path to the AsciiDoc file.

Returns:

  • (Hash)
    A hash of the document attributes.


26
27
28
29
# File 'lib/sourcerer.rb', line 26

def self.load_attributes path
  doc = Asciidoctor.load_file(path, safe: :unsafe)
  doc.attributes
end

.load_include(path_to_main_adoc, tag: nil, tags: [], leveloffset: nil) ⇒ String

Loads a snippet from an AsciiDoc file using an `include::` directive.

Parameters:

  • path_to_main_adoc (String)
    The path to the main AsciiDoc file.
  • tag (String) (defaults to: nil)
    A single tag to include.
  • tags (Array<String>) (defaults to: [])
    An array of tags to include.
  • leveloffset (Integer) (defaults to: nil)
    The level offset for the include.

Returns:

  • (String)
    The content of the included snippet.


38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/sourcerer.rb', line 38

def self.load_include path_to_main_adoc, tag: nil, tags: [], leveloffset: nil
  opts = []
  opts << "tag=#{tag}" if tag
  opts << "tags=#{tags.join(',')}" if tags.any?
  opts << "leveloffset=#{leveloffset}" if leveloffset

  snippet_doc = <<~ADOC
    include::#{path_to_main_adoc}[#{opts.join(', ')}]
  ADOC

  doc = Asciidoctor.load(
    snippet_doc,
    safe: :unsafe,
    base_dir: File.expand_path('.'),
    header_footer: false,
    attributes: { 'source-highlighter' => nil }) # disable extras

  # Get raw text from all top-level blocks
  doc.blocks.map(&:content).join("\n")
end

.load_render_data(data_file, attrs_source) ⇒ Object



212
213
214
215
216
217
218
219
# File 'lib/sourcerer.rb', line 212

def self.load_render_data data_file, attrs_source
  if attrs_source
    attrs = load_attributes(attrs_source)
    SchemaGraphy::Loader.load_yaml_with_attributes(data_file, attrs)
  else
    SchemaGraphy::Loader.load_yaml_with_tags(data_file)
  end
end

.process_block_content(content) ⇒ Array<String>

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Processes the content of a block to extract commands. It handles line continuations and skips comments.

Parameters:

  • content (String)
    The content of the block.

Returns:

  • (Array<String>)
    An array of commands.


305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/sourcerer.rb', line 305

def self.process_block_content content
  processed_commands = []
  current_command = ''
  content.each_line do |line|
    stripped_line = line.strip
    next if stripped_line.start_with?('#') # Skip comments

    if stripped_line.end_with?('\\')
      current_command += "#{stripped_line.chomp('\\')} "
    else
      current_command += stripped_line
      processed_commands << current_command unless current_command.empty?
      current_command = ''
    end
  end
  processed_commands
end

.render_erb(template_content, context) ⇒ Object



228
229
230
231
# File 'lib/sourcerer.rb', line 228

def self.render_erb template_content, context
  require 'erb'
  ERB.new(template_content, trim_mode: '-').result_with_hash(context)
end

.render_liquid(template_file, template_content, context, includes_load_paths) ⇒ Object



233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/sourcerer.rb', line 233

def self.render_liquid template_file, template_content, context, includes_load_paths
  require_relative 'sourcerer/jekyll'
  require_relative 'sourcerer/jekyll/liquid/filters'
  require_relative 'sourcerer/jekyll/liquid/tags'
  require 'liquid' unless defined?(Liquid::Template)
  Sourcerer::Jekyll.initialize_liquid_runtime

  # Determine includes root; add template directory to search paths
  fallback_templates_dir = File.expand_path('.', Dir.pwd)
  template_dir = File.dirname(File.expand_path(template_file))
  # For templates that use includes like cfgyml/config-property.adoc.liquid,
  # we need the parent directory of the template's directory as well
  template_parent_dir = File.dirname(template_dir)

  paths = if includes_load_paths.any?
            includes_load_paths
          else
            [template_parent_dir, template_dir, fallback_templates_dir]
          end

  # Create a fake Jekyll site
  site = Sourcerer::Jekyll::Bootstrapper.fake_site(
    includes_load_paths: paths,
    plugin_dirs: [])

  # Setup file system for includes with multiple paths
  file_system = Sourcerer::Jekyll::Liquid::FileSystem.new(paths)

  template = Liquid::Template.parse(template_content)
  options = {
    registers: {
      site: site,
      file_system: file_system
    }
  }
  template.render(context, options)
end

.render_outputs(render_config) ⇒ Object

Renders templates or converter outputs based on a configuration.

Parameters:

  • render_config (Array<Hash>)
    A list of render configurations.


140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/sourcerer.rb', line 140

def self.render_outputs render_config
  return if render_config.nil? || render_config.empty?

  render_config.each do |render_entry|
    if render_entry[:converter]
      render_with_converter(render_entry)
      next
    end

    data_obj = render_entry[:key] || 'data'
    attrs_source = render_entry[:attrs]
    engine = render_entry[:engine] || 'liquid'

    render_template(
      render_entry[:template],
      render_entry[:data],
      render_entry[:out],
      data_object: data_obj,
      attrs_source: attrs_source,
      engine: engine)
  end
end

.render_template(template_file, data_file, out_file, data_object: 'data', includes_load_paths: [], attrs_source: nil, engine: 'liquid') ⇒ Object

Renders a single template with data.

Parameters:

  • template_file (String)
    The path to the template file.
  • data_file (String)
    The path to the data file (YAML).
  • out_file (String)
    The path to the output file.
  • data_object (String) (defaults to: 'data')
    The name of the data object in the template.
  • includes_load_paths (Array<String>) (defaults to: [])
    Paths for Liquid includes.
  • attrs_source (String) (defaults to: nil)
    The path to an AsciiDoc file for attributes.
  • engine (String) (defaults to: 'liquid')
    The template engine to use.


172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/sourcerer.rb', line 172

def self.render_template template_file, data_file, out_file, data_object: 'data', includes_load_paths: [],
  attrs_source: nil, engine: 'liquid'
  data = load_render_data(data_file, attrs_source)
  out_file = File.expand_path(out_file)
  FileUtils.mkdir_p(File.dirname(out_file))

  template_path = File.expand_path(template_file)
  template_content = File.read(template_path)

  # Prepare context
  context = {
    data_object => data,
    'include' => { data_object => data } # for compatibility with {% include ... %} expecting include.var
  }

  rendered = case engine.to_s
             when 'erb' then render_erb(template_content, context)
             when 'liquid' then render_liquid(template_file, template_content, context, includes_load_paths)
             else raise ArgumentError, "Unsupported template engine: #{engine}"
             end

  File.write(out_file, rendered)
end

.render_templates(templates_config) ⇒ Object

Renders a set of templates based on a configuration.

Parameters:

  • templates_config (Array<Hash>)
    An array of template configurations.


133
134
135
# File 'lib/sourcerer.rb', line 133

def self.render_templates templates_config
  render_outputs(templates_config)
end

.render_with_converter(render_entry) ⇒ Object

Raises:

  • (ArgumentError)


196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/sourcerer.rb', line 196

def self.render_with_converter render_entry
  data_file = render_entry[:data]
  out_file = render_entry[:out]
  raise ArgumentError, 'render entry missing :data' unless data_file
  raise ArgumentError, 'render entry missing :out' unless out_file

  data = load_render_data(data_file, render_entry[:attrs])
  converter = resolve_converter(render_entry[:converter])
  rendered = converter.call(data, render_entry)
  raise ArgumentError, 'converter returned non-string output' unless rendered.is_a?(String)

  out_file = File.expand_path(out_file)
  FileUtils.mkdir_p(File.dirname(out_file))
  File.write(out_file, rendered)
end

.resolve_converter(converter) ⇒ Object

Raises:

  • (ArgumentError)


221
222
223
224
225
226
# File 'lib/sourcerer.rb', line 221

def self.resolve_converter converter
  return converter if converter.respond_to?(:call)
  return Object.const_get(converter) if converter.is_a?(String)

  raise ArgumentError, "Unsupported converter: #{converter.inspect}"
end