|
| 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 & |
| 323 | + # < becomes < |
| 324 | + # > becomes > |
| 325 | + $summary.=subst(/ '&' /, '&'):g; |
| 326 | + $summary.=subst(/ '<' /, '<'):g; |
| 327 | + $summary.=subst(/ '>' /, '>'):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