Skip to content

Commit add5844

Browse files
committed
coleman advent 2024 draft: simple-html-dashboard
1 parent f1c82f8 commit add5844

File tree

2 files changed

+489
-0
lines changed

2 files changed

+489
-0
lines changed
Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
= Generating an HTML Dashboard With Vanilla Raku =
2+
3+
The goal of this post is to demonstrate some Raku features by accomplishing
4+
something useful in about 100 lines of Raku. We're going to avoid using
5+
libraries, and instead use only what's available to us in the core language.
6+
7+
Our task requires wrapping the GitHub CLI and generating an HTML dashboard.
8+
We'll query the recently updated issues for some core Raku repositories,
9+
parse the CLI's output into structured data, and write out templated HTML
10+
to a file.
11+
12+
Along the way, we'll learn about
13+
14+
* Running a subprocess and capturing it's output
15+
* Opening and writing to files
16+
* String substitution
17+
* Raku's quoting constructs
18+
19+
Raku is a powerful language with lots of syntax, so we will try to proceed
20+
slowly, explaining each part of our script as we go. We will provide links
21+
to the official documentation where appropriate.
22+
23+
The repos we will be concerned with are
24+
25+
* https://github.com/raku/doc-website[raku/doc-website] - tools that generate HTML, CSS, and JavaScript
26+
for https://docs.raku.org[docs.raku.org], the official docs website
27+
* https://github.com/raku/doc[raku/doc] - content for the docs website
28+
* https://github.com/moarvm/moarvm[moarvm/moarvm] - virtual machine in C that Rakudo targets
29+
* https://github.com/rakudo/rakudo[rakudo/rakudo] - flagship Raku implementation
30+
* https://github.com/raku/nqp[raku/nqp] - the "not quite perl" intermediate representations
31+
32+
Let's get started.
33+
34+
=== Part 1: Calling Another Program ===
35+
36+
The command line utility we'll use is the GitHub CLI.
37+
38+
The GitHub CLI can fetch up to 1000 issues at a time with a command
39+
like the following.
40+
41+
----
42+
gh issue list -R raku/doc-website --state closed,open --search 'sort:updated-desc'
43+
----
44+
45+
We're only going to concern ourselves with 50, and sort
46+
them by recently updated. We include the closed as well as the open issues,
47+
because an issue being recently closed is important information for Raku
48+
contributors.
49+
50+
The only argument we need to parameterize is `-R`. We have 5 repos we'll need to
51+
pass to `-R`, so lets make an array.
52+
53+
[source,raku]
54+
----
55+
my @repos = <<
56+
raku/doc-website
57+
raku/doc
58+
moarvm/moarvm
59+
rakudo/rakudo
60+
raku/nqp
61+
>>;
62+
----
63+
64+
Next, let's loop through that array and shell out to gh with the https://docs.raku.org/routine/run[run builtin subroutine].
65+
66+
[source,raku]
67+
----
68+
for @repos -> $repo {
69+
say "getting issues for $repo";
70+
run("gh", "issue", "list", "-R", "$repo", "--search", "sort:updated-desc",
71+
"--limit", "50", "--state", "all");
72+
}
73+
----
74+
75+
This would be enough if we wanted to run this script locally and look at the
76+
output in our own terminal, but we.
77+
78+
79+
=== Part 2: Program Structure ===
80+
81+
To keep our script organized, we'll break it up into functions, or _subroutines_
82+
as Raku calls them. For now we're going to leave some details out and focus on the
83+
program's structure.
84+
85+
We'll start by defining `sub MAIN()`, a special subroutine that specifies the
86+
entrypoint to our program. Yes, it's all caps.
87+
88+
[source,raku]
89+
----
90+
#!/usr/bin/env raku
91+
92+
sub MAIN() {
93+
# program starts here...
94+
}
95+
----
96+
97+
We can define another function `get-issues` that takes a repo name as a
98+
parameter. We'll be calling this inside a loop. This function will call
99+
`gh`, parse its output, and return structured data.
100+
101+
[source,raku]
102+
----
103+
sub get-issues($repo) {
104+
# encapsulate calling gh and parsing output
105+
}
106+
----
107+
108+
Finally, we'll create a `write-document` function that accepts an open
109+
file handle and a hash of all the data we've gathered into memory.
110+
111+
[source,raku]
112+
----
113+
sub write-document($h, %data) {
114+
# Iterate our data and write it to a file handle
115+
}
116+
----
117+
118+
So far, I've avoided specifying types on either parameters or return
119+
values. Raku allows gradual typing, and enforces types with a mix of
120+
compile-time and run-time checks. We'll add some types later.
121+
122+
123+
=== Part 3: Capturing Output ===
124+
125+
Let's explore the implementation of the `get-issues` function. We need
126+
to capture the output of `gh`. Previously we shelled out like this.
127+
128+
[source,raku]
129+
----
130+
run("gh", "issue", "list", "-R", "$repo", "--search", "sort:updated-desc",
131+
"--limit", "50", "--state", "all");
132+
----
133+
134+
That dumps output to our terminal. Let's clean this up and capture the output.
135+
136+
[source,raku]
137+
----
138+
my @cmd-line = << gh issue list -R $repo --search "sort:updated-desc" --limit 50 --state "all" >>;
139+
my Proc $proc = run @cmd-line, :out;
140+
----
141+
142+
Our `@cmd-line` variable uses the `<< >>` array style, which will still let us
143+
interpolate `$repo`, but use space-separated elements.
144+
145+
Furthermore, we pass the `:out` symbolic parameter to `run`, which captures
146+
the process' stdout.
147+
148+
And we also add the builtin class `Proc` as a type annotation. This is
149+
for you, dear reader, to reinforce the fact that the `run` subroutine
150+
returns a `Proc`.
151+
152+
Now it's time to do something with the output. The default output of
153+
`gh issue list` is newline-delimited. The `lines` method transforms our
154+
output into an array of strings. One line of output for each issue.
155+
156+
[,raku]
157+
----
158+
my @lines = $proc.out.lines;
159+
----
160+
161+
Each line of output looks like this.
162+
163+
----
164+
4536 OPEN Run and Edit examples docs, wishlist 2024-12-01T00:04:33Z
165+
----
166+
167+
Conveniently, the output is tab-delimited.
168+
169+
Let's put it all together and finish our `get-issues` function.
170+
171+
[source,raku]
172+
----
173+
sub get-issues($repo) {
174+
175+
my @cmd-line = << gh issue list -R $repo --search "sort:updated-desc" --limit 50 --state "all" >>;
176+
my Proc $proc = run @cmd-line, :out;
177+
178+
my @lines = $proc.out.lines;
179+
180+
my @issues;
181+
182+
for @lines -> $line {
183+
my @fields = $line.split("\t");
184+
my %issue = (
185+
id => @fields[0].Int,
186+
status => @fields[1],
187+
summary => @fields[2],
188+
tags => @fields[3],
189+
updated-at => DateTime.new(@fields[4])
190+
);
191+
@issues.push(%issue)
192+
# ignore any parsing errors and continue looping
193+
CATCH { next }
194+
}
195+
196+
return @issues;
197+
}
198+
----
199+
200+
To summarize, we shell out to `gh issue list`, loop through all the output,
201+
and accumulate the data into an array of hashes. See the https://docs.raku.org/type/Hash[Hash]
202+
documentation for all the wonderful ways to build and manipulate hashes.
203+
204+
For good measure, we've coerced `id` into an `Int`
205+
(with an `.Int` method call) and parsed the `updated-at` date string into
206+
the builtin https://docs.raku.org/type/DateTime[DateTime type] (with the `new`
207+
class constructor).
208+
209+
Back in our `MAIN`, we can make use of our fully-implemented `get-issues` routine.
210+
For each $repo, we add to our `%data` object.
211+
212+
[,raku]
213+
----
214+
my @repos = <<
215+
raku/doc-website
216+
raku/doc
217+
moarvm/moarvm
218+
rakudo/rakudo
219+
raku/nqp
220+
>>;
221+
222+
my %data;
223+
224+
for @repos -> $repo {
225+
my @issues = get-issues($repo);
226+
%data{$repo} = @issues;
227+
}
228+
----
229+
230+
Our `%data` hash ends up with the keys being the repo name, and the associated
231+
value is the array of issues for that repo.
232+
233+
234+
=== Part 4: Rendering an HTML File ===
235+
236+
We have our data. Let's template it as HTML and write it to a file.
237+
238+
There are many ways to open a file in Raku, but they're all going
239+
to give you back https://docs.raku.org/type/IO/Handle[an `IO::Handle` object].
240+
241+
For no particular reason, we'll use the https://docs.raku.org/type/independent-routines#sub_open[standalone builtin `open`].
242+
The `:w` symbol here will open the file for writing, and truncate the
243+
file if it already exists.
244+
245+
[,raku]
246+
----
247+
my $filename = "report.html";
248+
my $fh = open $filename, :w;
249+
write-document($fh, %data)
250+
$fh.close;
251+
----
252+
253+
Actually, on second thought, let's spice things up. We can do the same thing but
254+
use https://docs.raku.org/syntax/given[given], which lets us avoid naming our
255+
file handle, and instead access it as the https://docs.raku.org/language/variables#The_$__variable[topic variable $_].
256+
257+
[,raku]
258+
----
259+
given open $filename, :w {
260+
write-document($_, %data);
261+
.close
262+
}
263+
----
264+
265+
All that's left to do is implement `write-document`.
266+
267+
The responsibility of our `write-document` routine is to write html to a
268+
file, and there are several ways of writing to a file. We will use
269+
the standalone https://docs.raku.org/type/independent-routines#sub_spurt[spurt routine].
270+
The first argument to `spurt` will be our `IO::Handle` to the open
271+
file, and the second argument will be strings, our fragments of
272+
templated HTML.
273+
274+
Since the start of our HTML document is a fairly long string, we can
275+
use https://docs.raku.org/language/quoting[HEREDOC style quoting].
276+
The various quoting constructs that Raku provides give us much
277+
of the power of string templating languages without requiring
278+
and additional libraries.
279+
280+
[,raku]
281+
----
282+
# document start
283+
spurt $h, q:to/END/;
284+
<!DOCTYPE html>
285+
<html lang="en">
286+
<head>
287+
<title>Issues in Raku core</title>
288+
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
289+
<meta name="robots" content="noindex">
290+
<link rel="stylesheet" href="https://envs.net/~coleman/css/style.css"/>
291+
<link rel="stylesheet" href="https://envs.net/css/fork-awesome.min.css"/>
292+
</head>
293+
<body id="body" class="dark-mode">
294+
END
295+
----
296+
297+
Everything between `q:to/END` and the closing `END` is treated as a single
298+
string argument to `spurt`. We used the `q:to` form since we didn't need
299+
to interpolate any variables.
300+
301+
When we _do_ need to interpolate variables, we can use the `qq:to` form and
302+
wrap our variables in curly brackets.
303+
304+
Let's loop through our nested `%data` hash to fill out the templated middlep
305+
part of our HTML document. We'll see `qq:to` and data interplolation in action.
306+
307+
[,raku]
308+
----
309+
for %data.kv -> $repo, @issues {
310+
spurt $h, qq:to/END/;
311+
<section>
312+
<h1>{$repo}</h1>
313+
<details open>
314+
<summary>Most recent issues (show/hide)</summary>
315+
END
316+
317+
for @issues -> %issue {
318+
# destructure values from %issue
319+
my ($issue-number, $summary, $status) = %issue<id summary status>;
320+
321+
# HTML escape (minimal)
322+
# & becomes &amp;
323+
# < becomes &lt;
324+
# > becomes &gt;
325+
$summary.=subst(/ '&' /, '&amp;'):g;
326+
$summary.=subst(/ '<' /, '&lt;'):g;
327+
$summary.=subst(/ '>' /, '&gt;'):g;
328+
329+
spurt $h, qq:to/END/;
330+
<div class="issue-container">
331+
<div class="issue-id"><a href="https://github.com/{$repo}/issues/{$issue-number}">{$issue-number}</a></div>
332+
<div class="issue-summary">{$summary}</div>
333+
<div class="issue-status">{$status}</div>
334+
</div>
335+
END
336+
}
337+
338+
# Section end
339+
spurt $h, q:to/END/;
340+
</details>
341+
</section>
342+
<hr/>
343+
END
344+
}
345+
----
346+
347+
We also did a little bit of HTML escaping on the `$summary` string. Note that
348+
the `.=` is an in-place modification of the `$summary` string,
349+
using https://docs.raku.org/type/Str#method_subst[method call assignment].
350+
Every `Str` has a `subst` method, and we're just calling that and assigning
351+
to ourselves in one go. The reason we need to do that is to escape some
352+
characters that will frequently appear in issue summaries, but are bad news
353+
for rendering to HTML. This isn't a totally injection-safe solution, but it's
354+
good enough for our purposes.
355+
356+
Finally we can end our `write-document` routine with closing tags.
357+
358+
[,raku]
359+
----
360+
# footer
361+
spurt $h, q:to/END/;
362+
</body>
363+
</html>
364+
END
365+
----
366+
367+
== Conclusion ==
368+
369+
I've published the results at https://envs.net/~coleman/raku/report.html
370+
371+
To keep something like this up to date, we will need to use `cron`, systemd timers,
372+
or some other scheduler, but that's beyond our scope here.
373+
374+

0 commit comments

Comments
 (0)