AsciiDoc, Go template, and Hugo (featuring Nix)

The title is a tongue-in-cheek reference to a recent writeup regarding extending Asciidoctor in Jekyll. Since I have a similar recent problem, we’ll stick to the themed naming. Besides, how many users are there setting up a Hugo project that mainly writes with Asciidoctor with custom extensions meanwhile setting up the whole development environment with Nix?

This post documents the process and its problems of injecting custom HTML in my Hugo-based website with content written used with custom Asciidoctor extensions then integrating them with Nix package manager.

foodogsquared

This dialog block is the bee’s knees.

Ezran

Yeah right, you ripped this segment off from the linked post. What. A. Hack.

foodogsquared

Well, at the very least I improved the dialog block as we’ll see later in the post.

In parallel to the referenced post, we’ll first go through how one would use features already available in Hugo and eventually using a nicer and more integrated solution of using custom Asciidoctor extensions. I’ll even throw in a walkthrough of setting the environment with Nix. Hopefully, this helps a fellow Hugo user facing similar problems.

In this post, it is assumed you’re using the following relevant components:

  • Hugo v0.110.0 and later versions.

  • Asciidoctor v2.0.x.

  • Nix v2.14 and later versions.

  • We’re also using nixpkgs from NixOS 23.05-unstable version, more specifically at commit 38263d02cf3a22e011e137b8f67cdf8419f28015 .

Hugo shortcodes

Thankfully in Hugo, injecting custom HTML is possible with shortcodes. This is what you’re likely to go for when using Hugo.

If we’re to use it, it would go something like in the following listing.

{{< chat "foodogsquared" >}}
Hello there, **world**!!!
{{< /chat >}}

This imaginary shortcode would allow markup to be rendered alongside the dialog box which is neat.

There are some additional use case we can keep in mind with this shortcode:

  • You could specify the state by adding the state key which represents different states/images of each character.

  • You can change the display name with name key.

  • You can pass reversed key to have the dialog box appear as reversed representing the character talking on the other side.

  • You could also specify the named parameters as well.

Overall, the following sample document should be enough to show the use cases.

{{< chat avatar="foodogsquared" state="nervous" >}}
This is becoming unnerving.
_Really_ unnerving.
{{< /chat >}}

{{< chat avatar="Ezran" state="disguised" name="A person in disguise" reversed="true" >}}
Hello there, stranger!
Could I have your wallet for a short inspection?
{{< /chat >}}

{{< chat foodogsquared >}}
NO!
{{< /chat >}}

All we have to do is to figure out how to render the HTML and put the file in the appropriate location. Hugo shortcodes use Go template on top of Hugo’s own selection of functions. Some familiarity of both are basically required to make use of it.

We’re skipping some prerequisite setup here which would require placing the right images in certain locations. This is easily inferred by the following shortcode.

Anyhoo, here’s one way to render it.

layouts/shortcodes/chat.html
\{{ $avatar := default (.Get "avatar") (.Get 0) }}
{{ $name := default $avatar (.Get "name") }}
{{ $state := default "default" (.Get "state") }}

<div class="dialogblock dialogblock__avatar--{{ anchorize $avatar }} {{ with .Get "reversed" }}reversed{{ end }}" title="{{ $name }}">
  <div class="dialogblock dialogblock__avatar">
      <img src="/icons/avatars/{{ anchorize $avatar }}/{{ anchorize $state }}.webp" alt="{{ $name }}"/>
  </div>
  <div class="dialogblock dialogblock__text">
    {{ $.Page.RenderString .Inner }}
  </div>
</div>

You can then use right away and it should work since the shortcode is processed after the content. In this case, Hugo will insert the shortcode output after Asciidoctor finished processing the document.

This is a nice solution if you want a quick and easy one. However, there are some shortcomings with this approach.

  • Hugo shortcodes are only available to Hugo. I would like to easily migrate between frameworks and relying on a framework-exclusive feature for my content that is already handled by a tool (Asciidoctor) is not a good way to start.

  • Asciidoctor already has a way to be extended. Might as well use it. This also relates to the first point that we’ll be delegating more work to Asciidoctor (which is good).

  • It is not aesthetically pleasing combining Hugo shortcodes and Asciidoctor content like that but that’s just personal preference.

  • Last but not least, Hugo shortcodes are very limited to what it can do compared to Asciidoctor. [1] One of the many things I like about Asciidoctor is the ability to assign roles which can effortlessly add more style and semantics to our chat blocks. Not to mention, you can assign an ID that can be referred from the document.

Asciidoctor extensions

The greatest feature with Asciidoctor is its extension system. Not only do we get a nice lightweight text markup format, we also get a nice text processor on top that can be modified for various purposes.

In an ideal case, the following sample should show enough use cases.

sample.adoc
[chat, foodogsquared, state=nervous]
====
This is unnerving.
_Really_ unnerving.
====

[chat, Ezran, state=disguised, name="A person in disguise", role=reversed]
====
Hello there, stranger!
Could I have your wallet for a short inspection?
====

[chat, foodogsquared, state=nervous]
====
NO!
====

Similarly to the shortcode component, our custom chat block should be able to handle markup in it. Also, it should be easy to state the character’s status among other things.

Compared to the Hugo shortcodes method, this is easier to handle especially with more complex dialogues. Not to mention with features such as assigning roles, you can make dialog blocks easier to customize. It’s even easier to extend its capabilities for this block with element attributes.

Here’s the chat block extension code in place. If you want to know about the details of creating it, you can see it in a dedicated section walking you through the code.

lib/asciidoctor/custom_extensions/chat_block_processor.rb
# frozen_string_literal: true

def to_kebab_case string
  string.gsub(/\s+/, '-')           # Replace all spaces with dashes.
        .gsub(/[^a-zA-Z0-9-]/, '')  # Remove all non-alphanumerical (and dashes) characters.
        .gsub(/-+/, '-')            # Reduce all dashes into only one.
        .gsub(/^-|-+$/, '')         # Remove all leading and trailing dashes.
        .downcase
end

class ChatBlock < Asciidoctor::Extensions::BlockProcessor
  use_dsl

  named :chat
  on_context :example
  name_positional_attributes 'avatar', 'state'
  default_attributes 'state' => 'default', 'avatarstype' => 'webp'

  def process(parent, reader, attrs)
    block = create_block parent, :pass, nil, attrs, content_model: :compound
    block.add_role('dialogblock')

    attrs['name'] ||= attrs['avatar']
    attrs['avatarsdir'] ||= './avatars'

    # You can think of this section as a pipeline constructing the HTML
    # component for this block. Specifically, we're building one component that
    # contains two output: the dialog image of our avatar and its content.
    block << (create_html_block block, %(
      <div class="dialogblock dialogblock__box dialogblock__avatar--#{attrs['avatar']} #{attrs['role']}" title="#{attrs['avatar']}">
        <div class="dialogblock dialogblock__avatar">
    ))

    avatar_sticker = "#{to_kebab_case attrs['avatar']}/#{to_kebab_case attrs['state']}.#{attrs['avatarstype']}"
    avatar_img_attrs = {
      'target' => parent.image_uri(avatar_sticker, 'avatarsdir'),
      'alt' => attrs['name']
    }
    block << (create_image_block block, avatar_img_attrs)

    block << (create_html_block block, %(
        </div>
        <div class="dialogblock dialogblock__text">
    ))

    parse_content block, reader

    block << (create_html_block block, %(
        </div>
      </div>
      ))

    block
  end

  private

  def create_html_block parent, html, attributes = nil
    create_block parent, :pass, html, attributes
  end
end

Take note we cannot make use of this extension yet since we didn’t register it in the Asciidoctor registry. Let’s create the file that does that.

lib/asciidoctor-custom-extensions.rb
# frozen_string_literal: true

require 'asciidoctor'
require 'asciidoctor/extensions'

require_relative './asciidoctor/custom_extensions/chat_block_processor'

Asciidoctor::Extensions.register do
  block ChatBlock if @document.basebackend? 'html'
end

With the asciidoctor command-line interface, we can then make use of it with the -r option.

asciidoctor -r ./lib/asciidoctor-custom-extensions sample.adoc

Using custom Asciidoctor extensions in Hugo

With that said, we haven’t integrated the custom extension with Hugo just yet. While Hugo has support for Asciidoctor extensions, it is limited in some form. Per Hugo documentation:

Notice that for security concerns only extensions that do not have path separators (either \, / or .) are allowed. That means that extensions can only be invoked if they are in one’s Ruby’s $LOAD_PATH (i.e., most likely, the extension has been installed by the user). Any extension declared relative to the website’s path will not be accepted.

— Hugo docs

This pretty much means I have to make my extensions installed as a Ruby Gem alongside the project setup.

A dialog on Hugo–Asciidoctor integration
foodogsquared

The current status for easily adding custom Asciidoctor extensions is not great, yes. But at least it’s better than it used to be which it only supported a fixed list of Asciidoctor extension to be used within Hugo.

Ezran

Why would it be restricted in the first place anyways?

foodogsquared

The maintainer is primarily concerned with security especially in regards to shelling out to external programs which is what Hugo is doing. This is already seen in project’s security model.

As far as I can tell, they’re trying to limit of that as much as possible. With the previous way of an allowlist of Asciidoctor extensions, it doesn’t seem to be reasonable especially the Asciidoctor ecosystem is more open-ended.

For the initial setup, we have to create the appropriate a gemspec file. Think of this as a blueprint for the Ruby gem.

asciidoctor-custom-extensions.gemspec
Gem::Specification.new do |s|
  s.name          = 'asciidoctor-custom-extensions'
  s.version       = '1.0.0'
  s.licenses      = ['MIT']
  s.summary       = 'My custom Asciidoctor extensions'
  s.authors       = ['Yor Neighme']
  s.email         = 'yor.neighme@example.com'
  s.metadata      = {
    'bug_tracker_uri' => 'https://example.com/yoruserneighme/website/issues',
    'source_code_uri' => 'https://example.com/yoruserneighme/website.git'
  }

  s.files = Dir['*.gemspec', 'lib/**/*']
  s.required_ruby_version = '>= 2.6'
  s.add_runtime_dependency 'asciidoctor', '~> 2.0'
end

Next, we build and then install the gem…​

gem build ./asciidoctor-custom-extensions.gemspec
gem install ./asciidoctor-custom-extensions*.gem

With our custom extension installed as a Ruby gem, we could add it to the list of Asciidoctor extensions in the Hugo configuration.

config.toml
[markup.asciidocExt]
extensions = [
  "asciidoctor-custom-extensions"
]

Hoorah! Now we could make use of our own Asciidoctor extensions.

More Asciidoctor extension examples

You can see a more detailed example within the source code of my website where I implemented several pet features for Asciidoctor which are mostly shorthand for several links. Here’s a non-exhaustive list of extensions I implemented:

If you want to test it, you can run the asciidoctor command. Mind the name of the extension which is whatever file that is placed on lib for our gem.

Remember, Hugo converts Asciidoc files by shelling out to asciidoctor executable. If the following command works then it should work with Hugo as well.

asciidoctor -r asciidoctor-custom-extensions sample.adoc

The end goal of the setup is done but there is a better way to set this all up. Specifically with Ruby, the most common way to manage Ruby environment is through Bundler. It feels pretty similar to Cargo for Rust or npm for Node. This is especially important once you make use of the wider ecosystem of Asciidoctor alongside Hugo.

To start, you’ll have to create a file named Gemfile. This dictates what Ruby gems to contain within your project environment. At this point, you could also specify what other Ruby gems to install including existing Asciidoctor extensions.

Gemfile
source 'https://rubygems.org'

gem 'asciidoctor'
gem 'asciidoctor-bibtex'
gem 'asciidoctor-rouge'
gem 'rouge'

gemspec

Next, we the install the environment with bundle install and voila! You now have a reproducible environment for your Asciidoctor extension.

Installing our own gem with gemspec directive

In the Gemfile code, installing the Asciidoctor extensions gem is done through the gemspec directive. It also installs the dependencies as indicated from the gemspec file. You can see more details from its online documentation. There should also be a manual page at gemfile(5) if you want to view it locally.

While viewing the HTML document in the browser might not be so pretty due to the lack of CSS rules for the dialog block, the more important thing to do is to check the HTML output. If it’s there, all you have to do is to add the CSS rules for the dialog block in the Hugo project.

Integrating the extensions with Nix

With the depicted setup, you would think it’s a pain to initialize the development environment. And you’d be right as the source code of the website uses more than Ruby and Hugo. For example, it uses a shell script to generate a webring which requires a separate program. Additionally, it uses certain features from Hugo such as Hugo modules which requires Git and Go runtime to be installed.

foodogsquared

Gee, it would be nice if there’s a solution that can bring all of the development environment with just a command.

Ezran

I wonder what that could be…​

The project mainly uses Nix to easily reproduce the development environment in a snap. Just list the required dependencies in shell.nix at the project root, run nix-shell, and voila!

shell.nix
{ pkgs ? import <nixpkgs> { } }:

with pkgs;

let
  gems = bundlerEnv {
    name = "hugo-website-gems";
    gemdir = ./.;
  };
in
mkShell {
  packages = [
    gems
    gems.wrappedRuby

    git
    go
    hugo
  ];
}

As much as possible, I would like to keep it consistently reproducible and self-contained with Nix as it can also be used to reliably deploy with the given environment similarly used to Docker containers. nixpkgs has support for setting up development environments with Ruby which is nice for us. All we have to do is to create a Nix environment as documented.

Setting up a Ruby environment is done with the pkgs.bundlerEnv function from nixpkgs. We have already set the prerequisites for setting up Ruby with the Gemfile. Now we have to give the arguments for it.

You should also remove the installed custom gem from the previous chapter as Nix already managed those steps for you.

pkgs.bundlerEnv {
  name = "foodogsquared-website-gems";
  gemdir = ./.;
}

The one thing that stands out is the gemdir attribute which points the directory containing Gemfile, its lockfile, and gemset.nix which contains the checksums of the Gems. To generate the last item, we use the bundix utility which is available in nixpkgs repo.

bundle lock
bundix

We can then add the Ruby environment in shell.nix.

Adding our Ruby environment to shell.nix as a diff
diff --git b/shell.nix a/shell.nix
index 3ed5ddc..9663c2b 100644
--- b/shell.nix
+++ a/shell.nix
@@ -2,9 +2,17 @@

 with pkgs;

+let
+  gems = bundlerEnv {
+    name = "hugo-website-gems";
+    gemdir = ./.;
+  };
+in
 mkShell {
   packages = [
-    asciidoctor
+    gems
+    gems.wrappedRuby
+
     git
     go
     hugo

Take note we also remove asciidoctor package in shell.nix since we already have an asciidoctor executable available from our gem which is preferable. This is the doing of bundlerEnv by including exported executables from the gemset into the environment.

To check that it is working, enter the Nix environment with nix-shell and rerun the asciidoctor command.

asciidoctor -r asciidoctor-custom-extensions sample.adoc

…​which you shouldn’t be able to successfully run. Instead, you should have the following results similar to the next listing.

Traceback (most recent call last):
        8: from /nix/store/n23v258hppvz4q9rcj0gd1l73cbxrx84-hugo-website-gems/bin/asciidoctor:33:in `<main>'
        7: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler.rb:164:in `setup'
        6: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler.rb:216:in `definition'
        5: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler/definition.rb:37:in `build'
        4: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler/dsl.rb:12:in `evaluate'
        3: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler/dsl.rb:49:in `eval_gemfile'
        2: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler/dsl.rb:49:in `instance_eval'
        1: from /nix/store/cpc9w06bj6n7j86gipprcjm2i1mlm0yw-gemfile-and-lockfile/Gemfile:9:in `eval_gemfile'
/nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler/dsl.rb:87:in `gemspec': There are no gemspecs at /nix/store/cpc9w06bj6n7j86gipprcjm2i1mlm0yw-gemfile-and-lockfile (Bundler::InvalidOption)
        8: from /nix/store/n23v258hppvz4q9rcj0gd1l73cbxrx84-hugo-website-gems/bin/asciidoctor:33:in `<main>'
        7: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler.rb:164:in `setup'
        6: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler.rb:216:in `definition'
        5: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler/definition.rb:37:in `build'
        4: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler/dsl.rb:12:in `evaluate'
        3: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler/dsl.rb:49:in `eval_gemfile'
        2: from /nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler/dsl.rb:49:in `instance_eval'
        1: from /nix/store/cpc9w06bj6n7j86gipprcjm2i1mlm0yw-gemfile-and-lockfile/Gemfile:9:in `eval_gemfile'
/nix/store/69v06rm4gpab35r8pdgg6jvk8p8fbh1n-bundler-2.4.8/lib/ruby/gems/2.7.0/gems/bundler-2.4.8/lib/bundler/dsl.rb:87:in `gemspec':  (Bundler::Dsl::DSLError)
[!] There was an error parsing `Gemfile`: There are no gemspecs at /nix/store/cpc9w06bj6n7j86gipprcjm2i1mlm0yw-gemfile-and-lockfile. Bundler cannot continue.

 #  from /nix/store/cpc9w06bj6n7j86gipprcjm2i1mlm0yw-gemfile-and-lockfile/Gemfile:9
 #  -------------------------------------------
 #
 >  gemspec
 #  -------------------------------------------

It turns out bundlerEnv doesn’t go well with local gems as NixOS/nixpkgs#197556 or nix-community/bundix#76 have shown. Fortunately though, someone has made a modified version of bundlerEnv ruby-nix that made it works for those use cases. Let’s make use of that.

But before we start, we’ll do one more thing which is to convert the Nix environment into a flake to allow easy inclusion and usage of Nix modules from the wider ecosystem.

Nix flakes is an experimental feature but it is still strongly recommended to learn and use it. While it is still possible to use the featured Nix module with channels, it can result in a subtly different setup since the module expects up to a certain version of nixpkgs.

That said, because it is an experimental feature, it has some setup required beforehand by setting experimental-features in nix.conf (i.e., experimental-features = nix-command flakes).

For starters, we’ll have to create a file named flake.nix at the project root and define its inputs and its outputs. We only need to enter into the development environment so we only have to create the devShells.default flake output attribute used by nix develop command.

{
  description = "Basic flake template for setting up development shells";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = inputs@{ self, nixpkgs, ... }:
    let systems = inputs.flake-utils.lib.defaultSystems;
    in inputs.flake-utils.lib.eachSystem systems (system: {
      devShells.default =
        import ./shell.nix { pkgs = import nixpkgs { inherit system; }; };
    });
}

All we have to do is to generate the lockfile to…​ lock in the dependencies with the following command.

Even if you don’t want to, running certain operations in Nix will still generate the lockfile anyways. We’re just being explicit to have a closer look on managing Nix flakes.

nix flake lock

And that’s pretty much it (at least for the purposes of this post). Instead of nix-shell, we now have to enter the development environment with nix develop.

Technically, you can still enter through nix-shell but it isn’t the same as nix-shell will use the nixpkgs from the channel list while nix develop uses nixpkgs from the lockfile.

And now we can add the ruby-nix as part of our flake.

diff --git a/flake.nix b/flake.nix
index e272e59..3a3ce0a 100644
--- a/flake.nix
+++ b/flake.nix
@@ -4,12 +4,18 @@
   inputs = {
     nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
     flake-utils.url = "github:numtide/flake-utils";
+    ruby-nix.url = "github:sagittaros/ruby-nix";
   };

   outputs = inputs@{ self, nixpkgs, ... }:
     let systems = inputs.flake-utils.lib.defaultSystems;
     in inputs.flake-utils.lib.eachSystem systems (system: {
-      devShells.default =
-        import ./shell.nix { pkgs = import nixpkgs { inherit system; }; };
+      devShells.default = let
+          pkgs = import nixpkgs { inherit system; };
+      in
+        import ./shell.nix {
+          inherit pkgs;
+          ruby-nix = inputs.ruby-nix.lib pkgs;
+        };
     });
 }

Just keep in mind that we have also modified shell.nix to also accept an attribute ruby-nix as part of its function. This is the part where we really make use of ruby-nix.

diff --git a/shell.nix b/shell.nix
index 9663c2b..8716b96 100644
--- a/shell.nix
+++ b/shell.nix
@@ -1,18 +1,20 @@
-{ pkgs ? import <nixpkgs> { } }:
+{ pkgs ? import <nixpkgs> { }, ruby-nix }:

 with pkgs;

 let
-  gems = bundlerEnv {
+  localGems = ruby-nix {
     name = "hugo-website-gems";
-    gemdir = ./.;
+    gemset = ./gemset.nix;
   };
 in
 mkShell {
-  packages = [
-    gems
-    gems.wrappedRuby
+  buildInputs = [
+    localGems.env
+    localGems.ruby
+  ];

+  packages = [
     git
     go
     hugo

With the setup done, we now have to update gemset.nix since ruby-nix expects a different output from the nixpkgs version. Specifically, it expects a generated output from the author’s fork of bundix. All we have to do is to run it.

We can easily run the author’s fork of bundix with the following command.

nix run github:sagittaros/bundix
A dialog on Nix flakes
foodogsquared

This is what I like with Nix flakes. It’s easier to run outside apps because of this.

Ezran

As long as the developer of that app uses Nix flakes and exported the app as part of the flake output.

Not to mention, it can take up space faster because each may have a different version of the common components like nixpkgs. Before you know it, you’ll have ten version of nixpkgs floating in your disk.

foodogsquared

Unfortunately, yeah. But that’s what makes Nix quite nice to use from an end user perspective. Plus, if you can always do garbage collection and even set it up as a scheduled task.

Once that part is done, we can then enter to the Nix environment with nix develop then rerun the asciidoctor command one more time.

asciidoctor -r asciidoctor-custom-extensions chat-block-sample.adoc

And it should successfully run this time. Congratulations! The project environment is reachable in just a nix develop away!

Final words

Hugo and Asciidoctor are both nice tools for personal websites. With features from Hugo like multilingual mode and taxonomies, it is easy to create and maintain a website. And with the rich syntax for various things such as includes, admonitions, and sidebars from Asciidoctor, it is a joy to write technical content (or at the very least way smoother compared to Markdown).

While it is easy to configure Hugo to use Asciidoctor, once you get into extending Asciidoctor, it can get overwhelming especially to someone who haven’t encountered a Ruby codebase before. Extending Asciidoctor is the better way for Asciidoc documents no question. It integrates better with the Asciidoc syntax, it looks nicer, and has more capabilities as earlier shown.

With the addition of wanting to create a portable development environment with Nix, it can become a mountain of worries. Though setting up Ruby environments for applications with Nix is a rocky process, it has resulted in a nice replicable development environment for me to use.

Hopefully, this post documented much of the setup’s problems as well as the solution. Now go crazy and create a overengineered pipeline with these tools!

Appendix A: Asciidoctor chat block extension walkthrough

If you’re interested in the process of creating the chat block extension with some details on interacting with Asciidoctor API then you’ve come to the right place.

Keep in mind the following user stories for this component which should be summarized in a sample document:

  • The user should easily name the character. Under the hood, the component takes care of handling the resulting filepath which the user have to keep in mind. For example, if the given name is El Pablo (i.e., [chat, El Pablo]), the resulting filepath should be in $AVATARSDIR/el-pablo/default.$AVATARSTYPE.

  • A character can have multiple names for various reasons (i.e., spoiler potential, intent of surprise). Thus, a user can configure the name to appear with the name attribute (i.e., [chat, El Pablo, name="A person in disguise"]).

  • The user can specify the state with the state element attribute (i.e., [chat, El Pablo, state=idle]). It could also be given as the second positional attribute (i.e., [chat, El Pablo, idle]).

  • The user can configure the avatars' image directory with avatarsdir attribute.

  • The user can configure the type of images to be used with avatarstype attribute similarly used to icontype.

In comparison to the Hugo shortcode version, you may have noticed the equivalent reversed option is missing. We’re now delegating those options with Asciidoctor roles which makes it easier to customize your dialog block. Now, you can add more styling options if you want to.

A chat block with two roles
[chat, foodogsquared, state=nervous, role="shook darken"]
====
This is unnerving.
_Really_ unnerving.
====

We’ll be using Asciidoctor 2.0.18 for the rest of the walkthrough.

Also, all code to this point will be shown as a diff. This is meant to be used with git apply and similar tools.

git apply patchfile

For our component, we’ll implement it as a block processor seeing as the proposed syntax is a block anyways. Let’s first start with the initial version which is basically copied over from the example of the previously linked page.

Creating the skeleton of our chat block component
diff --git b/lib/asciidoctor/custom_extensions/chat_block_processor.rb a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
new file mode 100644
index 0000000..558be81
--- /dev/null
+++ a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class ChatBlock < Asciidoctor::Extensions::BlockProcessor
+  use_dsl
+
+  named :chat
+  on_context :example
+  name_positional_attributes 'avatar', 'state'
+  default_attributes 'state' => 'default', 'avatarstype' => 'webp'
+
+  # TODO: Create the output.
+  def process(parent, reader, attrs)
+  end
+end

Let’s inspect what is being done here. In this template, we just defined how the chat block is going to be processed. More specifically, we only set the chat block to be usable on top of the example block similar to admonition blocks.

Next, let’s add in the expected output. In our case, it is a passthrough block since the HTML output is complex enough [2]. We’ll also add default values of some of the element attributes here that are not possible to declare from the previous default_attributes declaration.

Adding the expected output from our component
diff --git b/lib/asciidoctor/custom_extensions/chat_block_processor.rb a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
index 558be81..de02b90 100644
--- b/lib/asciidoctor/custom_extensions/chat_block_processor.rb
+++ a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
@@ -10,5 +10,12 @@ class ChatBlock < Asciidoctor::Extensions::BlockProcessor

   # TODO: Create the output.
   def process(parent, reader, attrs)
+    block = create_block parent, :pass, nil, attrs, content_model: :compound
+    block.add_role('dialogblock')
+
+    attrs['name'] ||= attrs['avatar']
+    attrs['avatarsdir'] ||= './avatars'
+
+    block
   end
 end
Asciidoctor content model and context

For the purposes of this post, there are two concepts within Asciidoctor that you need to know about blocks: content model and context.

Content model dictates what kind of content a block can hold. In the previous step, we have set the outer block with compound content model which could contain other blocks. We could then append blocks to that outer block with the following code.

Appending blocks onto a block with compound content model in Asciidoctor Ruby API
block = create_block parent, :section, nil, attrs

# Appending an image block.
block << (create_image_block block, {
    'target' =>  parent.image_uri("/icons/avatars/foodogsquared/default.webp", 'avatarsdir'),
    'alt' => 'foodogsquared'
})

# Appending a passthrough block that contains HTML code.
block << (create_block parent, :pass, %(<div>HELLO THERE</div>), nil)

Meanwhile, context defines an aspect of the content. Typically, it is used as the name of the block (e.g., an image block, a paragraph block). They’re quite similar to HTML elements in the sense that they’re both representing parts of the content (e.g., a link, a paragraph, a header, a section). In fact, they have the same name for parameters that changes an aspect of the component: attributes.

Context also implies the content model. For example, the image block have an empty content model, the section block has a compound content model, and the passthrough block which has a raw content model which holds unprocessed content. In the previous step of the walkthrough, we have created a passthrough block with a compound content model. This what enables us to create custom components easily with Asciidoctor’s built-in components. You’ll see this aspect as you’ll go through this section.

We still haven’t added it the HTML output yet. Let’s add that in.

Adding the HTML output
diff --git b/lib/asciidoctor/custom_extensions/chat_block_processor.rb a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
index de02b90..f4f566c 100644
--- b/lib/asciidoctor/custom_extensions/chat_block_processor.rb
+++ a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
@@ -8,7 +8,6 @@ class ChatBlock < Asciidoctor::Extensions::BlockProcessor
   name_positional_attributes 'avatar', 'state'
   default_attributes 'state' => 'default', 'avatarstype' => 'webp'

-  # TODO: Create the output.
   def process(parent, reader, attrs)
     block = create_block parent, :pass, nil, attrs, content_model: :compound
     block.add_role('dialogblock')
@@ -16,6 +15,17 @@ class ChatBlock < Asciidoctor::Extensions::BlockProcessor
     attrs['name'] ||= attrs['avatar']
     attrs['avatarsdir'] ||= './avatars'

+    block << (create_block block, :pass, %(
+      <div class="dialogblock dialogblock__box dialogblock__avatar--#{attrs['avatar']} #{attrs['role']}" title="#{attrs['avatar']}">
+        <div class="dialogblock dialogblock__avatar">
+          # Image
+        </div>
+        <div class="dialogblock dialogblock__text">
+          # Content
+        </div>
+      </div>
+    ), nil)
+
     block
   end
 end

Take note we didn’t add the proper blocks here yet. In this case, we have to add two blocks: an image block containing the avatar image and the dialog.

Searching around the documentation, I found two functions that can help with our next step: create_image_block and parse_content. However, parse_content already appends the blocks into the parent block (i.e., block). Thus, we have to split the HTML output up like so…​

Splitting the HTML output in preparation of adding the proper blocks
diff --git b/lib/asciidoctor/custom_extensions/chat_block_processor.rb a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
index f4f566c..914f618 100644
--- b/lib/asciidoctor/custom_extensions/chat_block_processor.rb
+++ a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
@@ -15,13 +15,24 @@ class ChatBlock < Asciidoctor::Extensions::BlockProcessor
     attrs['name'] ||= attrs['avatar']
     attrs['avatarsdir'] ||= './avatars'

+    # You can think of this section as a pipeline constructing the HTML
+    # component for this block. Specifically, we're building one component that
+    # contains two output: the dialog image of our avatar and its content.
     block << (create_block block, :pass, %(
       <div class="dialogblock dialogblock__box dialogblock__avatar--#{attrs['avatar']} #{attrs['role']}" title="#{attrs['avatar']}">
         <div class="dialogblock dialogblock__avatar">
-          # Image
+    ), nil)
+
+    # TODO: Create the image block here
+
+    block << (create_block block, :pass, %(
         </div>
         <div class="dialogblock dialogblock__text">
-          # Content
+    ), nil)
+
+    # TODO: Insert the content.
+
+    block << (create_block block, :pass, %(
         </div>
       </div>
     ), nil)

Then add in the proper blocks as well as handled the resulting image path to be linked. Just take note that we haven’t implemented the to_kebab_case function yet.

Adding the proper blocks
diff --git b/lib/asciidoctor/custom_extensions/chat_block_processor.rb a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
index 914f618..e12c62a 100644
--- b/lib/asciidoctor/custom_extensions/chat_block_processor.rb
+++ a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
@@ -23,14 +23,19 @@ class ChatBlock < Asciidoctor::Extensions::BlockProcessor
         <div class="dialogblock dialogblock__avatar">
     ), nil)

-    # TODO: Create the image block here
+    avatar_sticker = "#{to_kebab_case attrs['avatar']}/#{to_kebab_case attrs['state']}.#{attrs['avatarstype']}"
+    avatar_img_attrs = {
+      'target' => parent.image_uri(avatar_sticker, 'avatarsdir'),
+      'alt' => attrs['name']
+    }
+    block << (create_image_block block, avatar_img_attrs)

     block << (create_block block, :pass, %(
         </div>
         <div class="dialogblock dialogblock__text">
     ), nil)

-    # TODO: Insert the content.
+    parse_content block, reader

     block << (create_block block, :pass, %(
         </div>

Next, we’ll implement a few (well, only one) helper function on the way.

Implementing the helper functions
diff --git b/lib/asciidoctor/custom_extensions/chat_block_processor.rb a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
index e12c62a..5957e2d 100644
--- b/lib/asciidoctor/custom_extensions/chat_block_processor.rb
+++ a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
@@ -1,5 +1,13 @@
 # frozen_string_literal: true

+def to_kebab_case string
+  string.gsub(/\s+/, '-')           # Replace all spaces with dashes.
+        .gsub(/[^a-zA-Z0-9-]/, '')  # Remove all non-alphanumerical (and dashes) characters.
+        .gsub(/-+/, '-')            # Reduce all dashes into only one.
+        .gsub(/^-|-+$/, '')         # Remove all leading and trailing dashes.
+        .downcase
+end
+
 class ChatBlock < Asciidoctor::Extensions::BlockProcessor
   use_dsl

Now the extension is completely implemented! The next change is just a minor refactoring on creating them HTML fragments.

Refactoring passthrough block creation
diff --git b/lib/asciidoctor/custom_extensions/chat_block_processor.rb a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
index 5957e2d..33e8830 100644
--- b/lib/asciidoctor/custom_extensions/chat_block_processor.rb
+++ a/lib/asciidoctor/custom_extensions/chat_block_processor.rb
@@ -26,10 +26,10 @@ class ChatBlock < Asciidoctor::Extensions::BlockProcessor
     # You can think of this section as a pipeline constructing the HTML
     # component for this block. Specifically, we're building one component that
     # contains two output: the dialog image of our avatar and its content.
-    block << (create_block block, :pass, %(
+    block << (create_html_block block, %(
       <div class="dialogblock dialogblock__box dialogblock__avatar--#{attrs['avatar']} #{attrs['role']}" title="#{attrs['avatar']}">
         <div class="dialogblock dialogblock__avatar">
-    ), nil)
+    ))

     avatar_sticker = "#{to_kebab_case attrs['avatar']}/#{to_kebab_case attrs['state']}.#{attrs['avatarstype']}"
     avatar_img_attrs = {
@@ -38,18 +38,24 @@ class ChatBlock < Asciidoctor::Extensions::BlockProcessor
     }
     block << (create_image_block block, avatar_img_attrs)

-    block << (create_block block, :pass, %(
+    block << (create_html_block block, %(
         </div>
         <div class="dialogblock dialogblock__text">
-    ), nil)
+    ))

     parse_content block, reader

-    block << (create_block block, :pass, %(
+    block << (create_html_block block, %(
         </div>
       </div>
-    ), nil)
+      ))

     block
   end
+
+  private
+
+  def create_html_block parent, html, attributes = nil
+    create_block parent, :pass, html, attributes
+  end
 end

1. At least out of the box, you can still make the shortcode as capable as what you can do in Asciidoctor but it requires more work.
2. Plus, I don’t know how the full extent of the Asciidoctor API whether it lets create a new component easily.