DeCODE logo
Autopublish TechDocs to Confluence header image

Making Confluence your storage for technical documentation

Usually, for storing company-specific information, used some kind of documentation portal. We thought, why not to automate process of Confluence Space with pages generated from the codebase? When developers are writing the code, the best practice and LSP support are motivating them to write the documentation for their methods, classes and models. We can gather all of that by utilizing documentation generators and then publish it to a company-wide portal. In this example, let's use Rails application. We also will use RDoc for the parsing and generating of documentation

Prerequisites for documentation generation

  1. Install the rdoc gem
gem install rdoc --user
  1. Cover your models with RDoc comments, for example:
# Put here a description of your model/class
class DocumentationRecord < ApplicationRecord
  ##
  # :attr: general_docs
  # You need to specify the :attr: keyword in case you don't want the documentation 
  # to be referred by method, but rather a field
  belongs_to :general_docs
  ##
  # :attr: site
  # Every documented property should be visible in generated docs
  belongs_to :site, optional: true

  # You can also generate documentation for the methods of model
  # If you indent using two spaces after the comment - you can 
  # provide an example how to use this method
  #
  #   # call somewhere in your controller like this:
  #   documentation_record.validate_document
  def validate_document()
    puts "Validation of document started"
  end
end
  1. Obtain the necessary details/credentials from your Confluence
  • You need the CONFLUENCE_URI variable, which equal to https://your-organization-confluence.com/rest/api
  • The SPACE_KEY variable, which you can obtain by going to your organization space, where you will be uploading documentation
  • TOKEN, which has the permission to edit in that Confluence space
  1. You have to create your own custom RDoc generator.

Every RDoc generator needs to follow their structure, you can use use this code and make adjustments to your own liking. Pay an attention to method "template_path", because it indicates where you will be reading the template used for the generating of HTML for the Confluence.

One of the important notes, is that due to the fact that your generated HTML will be sent as an embedded part

The complete generator code:

require 'uri'
require 'net/http'
require 'rdoc/rdoc'
require "erb"
require 'json'

module RDoc
  module Generator
    class Confluencer
      ::RDoc::RDoc.add_generator self

      def initialize(store, options)
        @store = store
        @options = options
      end

      # Send a GET to the page name located inside the SPACE_KEY
      #
      # returns: version_number, content_id
      def get_confluence_page_version(name)
        uri = URI("#{CONFLUENCE_URI}/content?spaceKey=#{SPACE_KEY}&title=#{name}&expand=version")
        req = Net::HTTP::Get.new(uri)
        req['Authorization'] = "Bearer #{TOKEN}"
        res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }

        json_results = JSON.parse(res.body)['results']
        return json_results[0]['version']['number'], json_results[0]['id'] unless json_results.empty?
      end

      # In case the page already exists in confluence, it's api requires to use "PUT"
      # To determine if the page already exists or not, send beforehands a GET to the
      # SPACE_KEY for retrieving a version of existing document due to the
      # requirements of PUT - to have in JSON field "version", indicating the update
      def upload_confluence_page(name, contents, version, content_id)
        uri = URI("#{CONFLUENCE_URI}/content#{version.nil? ? "?spaceKey=#{SPACE_KEY}" : "/#{content_id}"}")
        version.nil? ? req = Net::HTTP::Post.new(uri) : req = Net::HTTP::Put.new(uri)
        req['Authorization'] = "Bearer #{TOKEN}"
        version_string = "\"version\": { \"number\": #{version + 1} }," unless version.nil?
        req.body = <<-JSON
        {
          #{version.nil? ? '' : version_string}
          "type":"page",
          "title":"#{name}",
          "space":{
            "key":"#{SPACE_KEY}"
          },
          "body":{
            "storage":{
              "value": "<ac:structured-macro ac:name=\\\"html\\\" ac:schema-version=\\\"1\\\"><ac:plain-text-body><![CDATA[<div>#{contents}</div>]]></ac:plain-text-body></ac:structured-macro>",
              "representation":"storage"
            }
          }
        }
        JSON
        req.content_type = 'application/json'
        Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
      end

      def generate
        @classes = @store.all_classes_and_modules.sort

        @classes.each do |klass|
          template_content = File.read(template_path)

          template = ERB.new template_content, trim_mode: '>'

          result = template
                   .result(binding)
                   .delete("\r\n\\")
                   .squeeze(' ')
                   .gsub('"', "'")

          version, content_id = get_confluence_page_version(klass.full_name)
          upload_confluence_page(klass.full_name, result, version, content_id)
        end
      end

      def class_dir; nil; end
      def file_dir; nil; end

      private

      def template_path
        '../template.html.erb'
      end
    end
  end
end
<style>
  pre {
    background: #f8f8f8;
  }
  .ruby-comment {
    display: block;
    color: #777;
  }
</style>
<div id='generator-body'>
  <h1> <%= klass.type %> <%= klass.full_name %></h1>
  <p><%= klass.description %></p>
  <% klass.each_section do |section, constants, attributes| %>
    <% if section.title %>## <%= section.title.strip %> <% end %>
    <% if section.comment %> <%=  section.description %><% end%>
    <% unless klass.constants.empty? %>
  <h2> Constants </h2>
  <table>
    <thead>
      <tr><th>Name</th><th>Description</th></tr>
    </thead>
    <tbody>
   <% klass.constants.each do |const| %>
     <tr>
       <td><%= const.name %></td>
       <td><% unless const.description.empty? %> <%= const.description %> <%else%> Not documented <% end %> </td>
     </tr>
    <% end %>
    <% end %>
    <% unless attributes&.empty? %>
      <h2> Attributes </h2>
      <% attributes.each do |attr| %>
        <h3> <%= attr.name %> </h3>
        <% if attr.comment %> <%= attr.description %> <%else%> Not documented <% end %>
      <% end %>
    <% end %>
    <% klass.methods_by_type(section).each do |type, visibilities| %>
      <% next if visibilities.empty? %>
      <% visibilities.each do |visibility, methods| %>
        <% next if methods.empty? %>
        <h2><%= visibility.capitalize %> <%= type.capitalize %> Methods </h2>
        <% methods.each do |method|%>
          <h3><%= method.name %><%= method.param_seq %> </h3>
          <% if method.comment %> <%= method.description %>  <% else %> Not documented <%end%>
        <% end %>
      <% end %>
    <% end %>
  <% end %>
</div>
  1. You have to also invoke this generator with the files for which you want to have documentation
  options = RDoc::Options.new
  options.exclude = Regexp.union(options.exclude, /node_modules/)
  options.setup_generator 'confluencer'
  options.files = ['app']
  rdoc = RDoc::RDoc.new
  rdoc.document options

Photo by Annie Spratt on Unsplash