As I have been exploring Hugo I’m finding a lot of things I like but it seems that it doesn’t allow for custom pre-processors. That’s okay. I’ll just do it myself. After trying multiple ideas, there seems to be one big concession I needed to make. Since Hugo has no ability to call outside executables, there needs to be two pipelines that are happening at the same time. While not optimal, the final product actually works pretty well.
A first I thought that I could use the shortcodes to directly call the Lilypond binary, but Hugo does not allow calling outside programs. The other option I came across was using the abcjs library however it doesn’t support some of the more complex things I want to do. (e.g., quasi-Schenker graphs where stems and note lengths don’t matter) So for the solution I decided on I am leveraging the extensive features of Hugo, specifically short codes and data templates. The Gulp script will build the Lilypond files and write images to the static directory and the results to the data directory. Hugo will then pull the information from the data directory and include the files in the markdown pages.
Gulp
This is the main “magic” that is going to happen here. There will be two primary Gulp Tasks. The first task is a non-watching task that will just build all of the files. The second task will watch the lilypond files and rebuild the one that is changed. Both of them will then write the image information to a data file in the Hugo data directory.
gulpfile.ts
1/**
2 * Imports
3 */
4import * as cp from "child_process";
5import * as crypto from "crypto";
6import * as fs from "fs";
7import * as path from "path";
8import * as gulp from "gulp";
9// The type defs for gulp-tap are wrong.
10const tap = require("gulp-tap");
11import * as rimraf from "rimraf";
12import { BufferFile } from "vinyl";
13
14/**
15 * Script Consts
16 */
17const LILYPOND_BASE_OPTIONS = [
18 "-dbackend=eps",
19 "-dno-gs-load-fonts",
20 "-dinclude-eps-fonts",
21 "-dpixmap-format=png16m",
22 "-dresolution=96",
23 "--png",
24 "-daux-files=#f",
25 "-drelative-includes=#t"
26];
27const LILYPOND_IN_DIR = "assets/lilypond";
28const LILYPOND_MAP = new Map();
29const LILYPOND_OUT_DIR = "static/images/lilypond";
30
31/**
32 * Main Gulp Task Functions
33 */
34
35/**
36 * This function will clean out directories and build all lilypond excerpts
37 * @param cb
38 */
39// This will clean out dirs and build all files.
40function buildAllLily(cb: () => void): void {
41 // make sure the out directory exists...
42 fs.mkdirSync(LILYPOND_OUT_DIR, { recursive: true });
43
44 // clean old files
45 cleanOutDirs();
46
47 // read all of the files in the lilypond directory and hash/compile
48 return gulp
49 .src([`${LILYPOND_IN_DIR}/**/*.ly`, `!${LILYPOND_IN_DIR}/headers/*`])
50 .pipe(
51 tap((file: BufferFile) => {
52 // calculate new hash
53 file.hash = calcHash(file.contents);
54
55 // build file
56 buildImg(`${LILYPOND_IN_DIR}/${file.relative}`, file.hash);
57
58 LILYPOND_MAP.set(file.relative, file.hash);
59 })
60 );
61}
62
63/**
64 * Dumps the lilypond file map to data dir
65 * @param cb
66 */
67function writeLilyMap(cb: () => void) {
68 return fs.writeFile(
69 "data/lilypond.json",
70 JSON.stringify(mapToObject(LILYPOND_MAP)),
71 () => {
72 cb();
73 }
74 );
75}
76
77/**
78 * Watches lilypond files and processes changes
79 */
80function watchFiles() {
81 // load current files in hash so we don't reprocess existing files.
82 try {
83 let jsonFile = fs.readFileSync("data/lilypond.json", "utf8");
84 let currentRecords = JSON.parse(jsonFile);
85 for (let [key, val] of Object.entries(currentRecords)) {
86 LILYPOND_MAP.set(key, val);
87 }
88 } catch (e) {
89 console.log("No data file found. Using blank hash map.");
90 }
91
92 // setup gulp watch task
93 const watchTask = gulp.watch([
94 `${LILYPOND_IN_DIR}/**/*.ly`,
95 `!${LILYPOND_IN_DIR}/headers/*`
96 ]);
97
98 // since we are only watching lilypond files, we want to be notified
99 // about everything
100 watchTask.on("all", (_, fPath) => {
101 let contents = fs.readFileSync(fPath);
102 let hash = calcHash(contents);
103 buildImg(`${fPath}`, hash);
104 LILYPOND_MAP.set(path.relative(LILYPOND_IN_DIR, fPath), hash);
105 writeLilyMap(() => {});
106 });
107
108 return watchTask;
109}
110
111/**
112 * Helper Functions
113 */
114
115/**
116 * Creates the lilypond img file and saves to OUT_DIR
117 * @param fileName string
118 * @param hash string
119 */
120function buildImg(fileName: string, hash: string) {
121 // We need to use Array.from to actually clone the array or headaches happen.
122 let options = Array.from(LILYPOND_BASE_OPTIONS);
123 options.push(`--include=/app/assets/lilypond/headers`);
124 // Add output file
125 options.push(`--output=/app/${LILYPOND_OUT_DIR}/${hash}`);
126 // Add input file
127 options.push(`/app/${fileName}`);
128
129 let dockerOptions = [
130 "run",
131 "--rm",
132 "-v", `${process.cwd()}:/app`,
133 "-w", "/app",
134 "gpit2286/lilypond:dev",
135 "lilypond"
136 ];
137
138 // Build the file
139 let res = cp.spawnSync("docker", dockerOptions.concat(options), {});
140 // log lilypond output... which is written to error... 🤷
141 console.log(res.stderr.toString("utf8"));
142}
143
144/**
145 * MD5 Hashes input buffer
146 * @param fileContents Buffer
147 */
148function calcHash(fileContents: Buffer): string {
149 return crypto
150 .createHash("md5")
151 .update(fileContents)
152 .digest("hex");
153}
154
155/**
156 * Cleans LILYPOND_OUT_DIR and data file.
157 */
158function cleanOutDirs() {
159 // remove all previous files
160 rimraf.sync(`${LILYPOND_OUT_DIR}/*`);
161 rimraf.sync("data/lilypond.json");
162}
163
164/**
165 * Turns a map into an object so that JSON.stringify works properly
166 * @param map Map<any, any>
167 */
168function mapToObject<K, V>(map: Map<K, V>) {
169 const out = Object.create(null);
170 map.forEach((value: V, key: K) => {
171 if (value instanceof Map) {
172 out[key] = mapToObject(value);
173 } else {
174 out[key] = value;
175 }
176 });
177 return out;
178}
179
180exports.buildAll = gulp.series(buildAllLily, writeLilyMap);
181exports.watch = gulp.series(watchFiles);
Hugo
For the most part, Hugo is going to do what Hugo does. We will still use the hugo server
to watch and recompile, but we need to connect the Gulp output to the website itself. For this, I decided to create a shortcode in Hugo.
layouts/shortcodes/lilypond.html
1{{/*
2 Lilypond shortcode
3 lilypond code_path title
4 codepath - path to lilypond include beyond the assets/lilypond without the ly
5 title - excerpt title
6*/}}
7
8{{ $fullPath := printf "%s.ly" (.Get 0) }}
9{{ $hash := index (.Site.Data.lilypond) $fullPath }}
10
11<figure>
12 <img
13 data-src="/images/lilypond/{{ print $hash }}.png"
14 data-sizes="auto"
15 alt="{{ print (.Get 1) }}"
16 title="{{ print (.Get 1) }}"
17 class="lazyload">
18 <figcaption
19 class="image-caption">
20 {{ print (.Get 1) }}
21 </figcaption>
22</figure>
The last part of this is adding a couple of config options to the config.toml. We ignore the files in the assets/lilypond
so that Hugo doesn’t recompile when the Lilypond files are edited. The second exclude, .eps files, is because when Lilypond generates the png files, it briefly creates an eps file. This eps file is then noticed by Hugo and Hugo tries to copy it from the static folder to the public folder. When Hugo goes to copy the file, it has already been deleted and Hugo throws all kinds of errors. So, we just tell Hugo to ignore the file when it’s created.
ignoreFiles = [ "assets/lilypond", "eps$" ]
The good, the bad, and the ugly
For the most part this works great. Run gulp watch
and hugo server
and they both do what they are supposed to do – almost. There is currently some discussion about short codes and when they should re-generate. Although a little bit of a headache, until Hugo gets a more robust shortcode regeneration scheme, you will just need to re-save the file that the lilypond shortcode exists on.