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.
This dialog block is the bee’s knees.
Yeah right, you ripped this segment off from the linked post. What. A. Hack.
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 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.
This pretty much means I have to make my extensions installed as a Ruby Gem alongside the project setup.
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.
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 -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.
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.
Gee, it would be nice if there’s a solution that can bring all of the development environment with just a command.
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
.
shell.nix
as a diffdiff --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 |
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 |
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
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 toicontype
.
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.
[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
|
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.
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.
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
We still haven’t added it the HTML output yet. Let’s add that in.
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…
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.
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.
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.
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