website/content/posts/2023-04-10-asciidoc-go-template-and-hugo-featuring-nix/index.adoc

31 KiB
Raw Blame History


title: "AsciiDoc, Go template, and Hugo (featuring Nix)" publishdate: 2023-04-10T00:00:00+00:00

tags: - Asciidoctor - Hugo - Nix ---

AsciiDoc, Go template, and Hugo (featuring Nix)

Gabriel Arazas <foodogsquared@foodogsquared.one> v1.0.1, 2023-04-11: Improved conclusion

The title is a tongue-in-cheek reference to a recent writeup regarding extending Asciidoctor in Jekyll. Since I have a similar recent problem, well 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 bees 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 well see later in the post.

In parallel to the referenced post, well 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. Ill even throw in a walkthrough of setting the environment with Nix. Hopefully, this helps a fellow Hugo user facing similar problems.

Note

In this post, it is assumed youre using the following relevant components:

  • Hugo v0.110.0 and later versions.

  • Asciidoctor v2.0.x.

  • Nix v2.14 and later versions.

  • Were 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 youre likely to go for when using Hugo.

If were 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 Hugos own selection of functions. Some familiarity of both are basically required to make use of it.

Note

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

Anyhoo, heres one way to render it.

layouts/shortcodes/chat.html
Unresolved directive in <stdin> - include::./assets/hugo-shortcode-chat.html[]

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 well be delegating more work to Asciidoctor (which is good).

  • It is not aesthetically pleasing combining Hugo shortcodes and Asciidoctor content like that but thats 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
link:git:{doccontentref}[role=include]

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 characters 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. Its even easier to extend its capabilities for this block with element attributes.

Heres 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
link:git:{doccontentref}[role=include]

Take note we cannot make use of this extension yet since we didnt register it in the Asciidoctor registry. Lets create the file that does that.

lib/asciidoctor-custom-extensions.rb
link:git:{doccontentref}[role=include]

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 havent integrated the custom extension with Hugo just yet. While Hugo has support for Asciidoctor extensions, it is limited in some form. Per swh:swh:1:cnt:a98898821c30a1554c300c909cd29600059f436a;origin=https://github.com/gohugoio/hugo;visit=swh:1:snp:41c409e12a0a2aee3dd77f3270736828b60dc5e6;anchor=swh:1:rev:f1e8f010f5f5990c6e172b977e5e2d2878b9a338;path=/docs/content/en/content-management/formats.md;lines=96[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 ones Rubys $LOAD_PATH (i.e., most likely, the extension has been installed by the user). Any extension declared relative to the websites 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 HugoAsciidoctor integration

The current status for easily adding custom Asciidoctor extensions is not great, yes. But at least its better than it used to be which it only swh:swh:1:cnt:8cc3e79e67d7d197f5211c182f5a216b06c7cb9e;origin=https://github.com/gohugoio/hugo;visit=swh:1:snp:41c409e12a0a2aee3dd77f3270736828b60dc5e6;anchor=swh:1:rev:f0266e2ef3487bc57dd05402002fc816e3b40195;path=/markup/asciidocext/asciidocext_config/config.go;lines=34-42[supported a fixed list of Asciidoctor extension to be used within Hugo].

Why would it be restricted in the first place anyways?

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 projects security model.

As far as I can tell, theyre trying to limit of that as much as possible. With the previous way of an allowlist of Asciidoctor extensions, it doesnt 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
link:git:{doccontentref}[role=include]

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 github:foo-dogsquared/website[source code of my website, rev=ed7ba855707a2ca13ab33c4eebab60257bff8880, path=gems] where I implemented several pet features for Asciidoctor which are mostly shorthand for several links. Heres a non-exhaustive list of extensions I implemented:

  • github:foo-dogsquared/website[github: inline macro to easily create links from GitHub., rev=ed7ba855707a2ca13ab33c4eebab60257bff8880, path=gems/lib/asciidoctor/github-link-inline-macro] Mainly inspired from Nix flake references syntax. I link a lot that is mainly hosted on GitHub so why not a macro for it.

  • github:foo-dogsquared/website[An inline macro to link SWHIDs with swh:., rev=ed7ba855707a2ca13ab33c4eebab60257bff8880, path=gems/lib/asciidoctor/swhid-inline-macro] I can easily create references with SWHIDs by storing each of them in a document attribute and swh:{attribute-name} away.

  • github:foo-dogsquared/website[An include processor for transcluding content from other Git revisions., rev=ed7ba855707a2ca13ab33c4eebab60257bff8880, path=gems/lib/asciidoctor/git-blob-include-processor] I use this to create dedicated branches for certain content (such as github:foo-dogsquared/website[the very article youre viewing, rev={doccontentref}]) to make creating sample code to be easier. This is what I use the simply refer to files and dynamically generate diffs which makes writing code walkthroughs a bit more fun.

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.

Tip

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, youll 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
link:git:{doccontentref}[role=include]

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 man: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 its 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 its a pain to initialize the development environment. And youd be right as the source code of the website uses more than Ruby and Hugo. For example, it uses github:foo-dogsquared/website[a shell script to generate a webring, rev=ed7ba855707a2ca13ab33c4eebab60257bff8880, path=bin/openring-create] 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 theres 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
link:git:{doccontentref}~1[role=include]

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.

Note

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 github:nix-community/bundix[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
link:git:{doccontentref}~1[role=include]

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 shouldnt be able to successfully run. Instead, you should have the following results similar to the next listing.

Unresolved directive in <stdin> - include::./assets/bundlerenv-error[]

It turns out bundlerEnv doesnt go well with local gems as github:NixOS/nixpkgs[issue=197556] or github:nix-community/bundix[issue=76] have shown. Fortunately though, someone has made a modified version of bundlerEnv github:sagittaros/ruby-nix[ruby-nix] that made it works for those use cases. Lets make use of that.

But before we start, well 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.

Warning

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, well 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.

link:git:{doccontentref}~2[role=include]

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

Tip

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

nix flake lock

And thats 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.

Note

Technically, you can still enter through nix-shell but it isnt 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.

link:git:{doccontentref}~2[role=include]

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.

link:git:{doccontentref}~1[role=include]

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 github:sagittaros/bundix[the authors fork of bundix]. All we have to do is to run it.

We can easily run the authors fork of bundix with the following command.

nix run github:sagittaros/bundix

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

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, youll have ten version of nixpkgs floating in your disk.

Unfortunately, yeah. But thats 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 havent 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 setups 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 youre interested in the process of creating the chat block extension with some details on interacting with Asciidoctor API then youve 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. Were 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.
====
Note

Well be using Asciidoctor 2.0.18 for the rest of the walkthrough.

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

git apply patchfile

For our component, well implement it as a block processor seeing as the proposed syntax is a block anyways. Lets 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
link:git:{doccontentref}~12[role=include]

Lets 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, lets add in the expected output. In our case, it is a passthrough block since the HTML output is complex enough [2]. Well 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
link:git:{doccontentref}~11[role=include]
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). Theyre quite similar to HTML elements in the sense that theyre 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 Asciidoctors built-in components. Youll see this aspect as youll go through this section.

We still havent added it the HTML output yet. Lets add that in.

Adding the HTML output
link:git:{doccontentref}~10[role=include]

Take note we didnt 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
link:git:{doccontentref}~9[role=include]

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

Adding the proper blocks
link:git:{doccontentref}~8[role=include]

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

Implementing the helper functions
link:git:{doccontentref}~7[role=include]

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

Refactoring passthrough block creation
link:git:{doccontentref}~6[role=include]

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 dont know how the full extent of the Asciidoctor API whether it lets create a new component easily.