diff --git a/blog b/blog
index d06ea67..6784af9 100755
--- a/blog
+++ b/blog
@@ -3,9 +3,12 @@ use v6.e.PREVIEW;
use DB;
use DB::BlogMeta;
+use DB::Series;
use DB::MarkdownPost;
use DB::IdrisPost;
+use Pretty::Table;
+
my %*SUB-MAIN-OPTS =
:named-anywhere,
:bundling,
@@ -70,6 +73,7 @@ multi MAIN(
#| Create a new markdown post
multi MAIN(
+ "post",
"new",
"markdown",
#| The path to the markdown file
@@ -96,6 +100,7 @@ multi MAIN(
#| Create a new idris post
multi MAIN(
+ "post",
"new",
"idris",
#| The path to the idris file
@@ -125,7 +130,8 @@ multi MAIN(
#| Update the last editied time on a post
multi MAIN(
- "touch",
+ "post",
+ "edit",
#| The post id to touch
Int:D $id,
#| The path of the database file
@@ -150,3 +156,229 @@ multi MAIN(
my $db = read-db $db-dir;
$db.render: $site-dir;
}
+
+#| Provide a table of posts, in newest to oldest order
+multi MAIN(
+ "post",
+ "list",
+ #| The path of the database directory
+ IO::Path(Str) :$db-dir = $default-db-dir,
+ #| The number of posts to show on a single page
+ Int :$per-page = 10;
+ #| The page number to show
+ Int :$page = 1;
+) {
+ my $db = read-db $db-dir;
+ my @pages =
+ $db.sorted-posts.rotor($per-page, :partial);
+ my @page = @pages[$page - 1].flat;
+ my $table = Pretty::Table.new:
+ title => "Posts (page $page/{@pages.elems})",
+ field-names => ["ID", "Title", "Type"];
+ for @page -> $pair {
+ my $id = $pair.key;
+ my $post = $pair.value;
+ # TODO: Terminal aware truncation
+ my $title = do if $post.title.chars > 50 {
+ "{$post.title.substr(0, 50)}..."
+ } else {
+ $post.title
+ };
+ my $type = do given $post {
+ when MarkdownPost {
+ "md"
+ }
+ when IdrisPost {
+ "idr"
+ }
+ default {
+ ""
+ }
+ }
+ $table.add-row: [$id, $title, $type];
+ }
+ say $table;
+}
+
+#| Display information about a post
+multi MAIN(
+ "post",
+ "info",
+ #| The id of the post to display information for
+ Int $id,
+ #| The path of the database directory
+ IO::Path(Str) :$db-dir = $default-db-dir,
+ #| Display all of the information and not just the primaries
+ Bool :$full,
+) {
+ my $db = read-db $db-dir;
+ my $post = $db.posts{$id.Int};
+ given $post {
+ say 'Title: ', .title;
+ say 'Type: ', .WHAT.^name;
+ say 'Source: ', .source.relative;
+ if .hidden {
+ say "Hidden";
+ }
+ if .all-slugs {
+ if $full {
+ say 'Slugs: ';
+ for .all-slugs -> $slug {
+ say ' * ', $slug;
+ }
+ } else {
+ say 'Primary Slug: ', .all-slugs[*-1];
+ }
+ } else {
+ say 'Slugs: []';
+ }
+ if .tags {
+ say 'Tags:';
+ for .tags -> $tag {
+ say ' * ', $tag;
+ }
+ }
+ given .posted-at {
+ say 'Posted At: ', .Date.Str, ' ', .hh-mm-ss;
+ }
+ if .edited-at {
+ if $full {
+ say 'Edits: ';
+ for .edited-at.sort.reverse {
+ say ' * ', .Date.Str, ' ', .hh-mm-ss;
+ }
+ } else {
+ given .edited-at.sort[*-1] {
+ say 'Last Edited At: ', .Date.Str, ' ', .hh-mm-ss;
+ }
+ }
+ }
+ }
+}
+
+#| Add or remove a tag to a post
+multi MAIN(
+ "post",
+ "tag",
+ #| The id of the post to display information for
+ Int $id,
+ #| The tag to add/remove
+ Str $tag,
+ #| remove the tag instead of adding it
+ Bool :$remove,
+ #| The path of the database directory
+ IO::Path(Str) :$db-dir = $default-db-dir,
+) {
+ my $db = read-db $db-dir;
+ my $post = $db.posts{$id.Int};
+ if $remove {
+ die "Post did not have requested tag"
+ unless $tag ~~ any($post.tags);
+ my $index = $post.tags.first: $tag;
+ $post.tags.=grep(* ne $tag);
+ } else {
+ die "Post already had requested tag"
+ if $tag ~~ any($post.tags);
+ $post.tags.push: $tag;
+ }
+ $db.write: $db-dir;
+}
+
+#| Create a new series
+multi MAIN(
+ "series",
+ "new",
+ #| The path of the database file
+ IO::Path(Str) :$db-dir = $default-db-dir,
+) {
+ my $db = read-db $db-dir;
+ say 'Series Title: ';
+ my $title = get;
+ say 'Series Description: ';
+ my $desc = get;
+
+ my $series = Series.new:
+ title => $title, desc => $desc;
+ my $id = $db.insert-series: $series;
+ say 'Series inserted with id ', $id;
+
+ $db.write: $db-dir;
+}
+
+
+#| Provide a table of series
+multi MAIN(
+ "series",
+ "list",
+ #| The path of the database directory
+ IO::Path(Str) :$db-dir = $default-db-dir,
+ #| The number of items to show on a single page
+ Int :$per-page = 10;
+ #| The page number to show
+ Int :$page = 1;
+) {
+ my $db = read-db $db-dir;
+ my @pages =
+ $db.series.sort(*.key).rotor($per-page, :partial);
+ my @page = @pages[$page - 1].flat;
+ my $table = Pretty::Table.new:
+ title => "Series (page $page/{@pages.elems})",
+ field-names => ["ID", "Title", "Desc"];
+ for @page -> $pair {
+ my $id = $pair.key;
+ my $series = $pair.value;
+ # TODO: Terminal aware truncation
+ my $title = do if $series.title.chars > 40 {
+ "{$series.title.substr(0, 50)}..."
+ } else {
+ $series.title
+ };
+ my $desc = do if $series.desc.chars > 40 {
+ "{$series.desc.substr(0, 50)}..."
+ } else {
+ $series.desc
+ };
+ $table.add-row: [$id, $title, $desc];
+ }
+ say $table;
+}
+
+#| Display the contents of a series
+multi MAIN(
+ "series",
+ "info",
+ #| The id of the series to display
+ Int $id,
+ #| The path of the database directory
+ IO::Path(Str) :$db-dir = $default-db-dir,
+) {
+ my $db = read-db $db-dir;
+ my $series = $db.series{$id.Int};
+ say 'Title: ', $series.title;
+ say 'Description:';
+ for $series.desc.lines -> $line {
+ say ' ', $line;
+ }
+ say 'Posts:';
+ for $series.post-ids -> $post-id {
+ my $post = $db.posts{$post-id};
+ say ' * ', $post-id, ': ', $post.title;
+ }
+}
+
+#| Add a post to a series
+multi MAIN(
+ "series",
+ "add",
+ #| The id of the series to add to
+ Int $series-id,
+ #| The id of the post to add
+ Int $post-id,
+ #| The path of the database directory
+ IO::Path(Str) :$db-dir = $default-db-dir,
+) {
+ my $db = read-db $db-dir;
+ my $series = $db.series{$series-id.Int};
+ $series.post-ids.push: $post-id.Int;
+ $db.write: $db-dir;
+}
diff --git a/db/posts/5.json b/db/posts/5.json
new file mode 100644
index 0000000..a689e5e
--- /dev/null
+++ b/db/posts/5.json
@@ -0,0 +1,14 @@
+{
+ "edited-at": [
+ ],
+ "hidden": false,
+ "idris": true,
+ "ipkg": "/home/nathan/Projects/Blog/projects/Idris/Idris.ipkg",
+ "posted-at": "2025-02-09T06:23:37.499533-05:00",
+ "slugs": [
+ ],
+ "source": "/home/nathan/Projects/Blog/projects/Idris/src/LessMacrosMoreTypes/Printf.md",
+ "tags": [
+ "idris"
+ ]
+}
\ No newline at end of file
diff --git a/db/series/0.json b/db/series/0.json
new file mode 100644
index 0000000..8661aef
--- /dev/null
+++ b/db/series/0.json
@@ -0,0 +1,7 @@
+{
+ "desc": "Macros are annoying, but an unfortunate fact of life in many programming languages. Especially in languages with nominally \"strong\" type systems, like Rust, macros are quite frequently needed to work around the type system to avoid needless repetition when consuming an API, generate formulaic boilerplate that only exists to please the type system, or work around the lack of variadic functions for things like printf. Lets explore the ways we can use dependently typed constructs to eliminate the need for such macros.",
+ "post-ids": [
+ 5
+ ],
+ "title": "Less Macros, More Types"
+}
\ No newline at end of file
diff --git a/lib/Atom.rakumod b/lib/Atom.rakumod
index 0efd238..caee5d8 100644
--- a/lib/Atom.rakumod
+++ b/lib/Atom.rakumod
@@ -39,6 +39,9 @@ sub post-to-item(BlogMeta:D $meta, Int:D $id, Post:D $post --> XML::Element) {
$xml.append: $author;
$xml.append:
XML::Element.new(:name, :attribs({:href($link), :rel}));
+ # TODO: Actually embed the content
+ $xml.append:
+ XML::Element.new(:name, :attribs({:src($link), :type}));
$xml.append:
XML::Element.new(:name, :nodes([$desc])) if $desc;
if $post.tags {
diff --git a/lib/Config.rakumod b/lib/Config.rakumod
index 68c8172..9a7ec07 100644
--- a/lib/Config.rakumod
+++ b/lib/Config.rakumod
@@ -10,20 +10,21 @@ use DB::Post;
unit class Config;
-# TODO: Support GFM admonitions
-method generate-post(Post:D $post, BlogMeta:D $meta) {
+method generate-post(Int:D $id, Post:D $post, $db) {
+ my $meta = $db.meta;
my $content = $post.render-html;
my $head = generate-head($meta, $post.title, $post.description);
my $body =
body [
site-header $meta;
article :class, [
- post-header $post;
+ post-header $id, $post, $db;
div :class, [
$content;
]
]
];
+ # TODO: Setup Comments
# TODO: Setup footer
# my $footer;
@@ -32,28 +33,7 @@ method generate-post(Post:D $post, BlogMeta:D $meta) {
$body
];
- "$html"
-}
-
-method generate-blurb(Int:D $id, $db) {
- my $post = $db.posts{$id};
- my $desc = $post.description;
- my $link = post-link $id, $post;
- div :class, [
- div :class, [
- a :href($link), span [
- h2 $post.title;
- ];
- ];
- post-info $post;
- if $desc ~~ Str:D {
- div :class, [
- p $post.description;
- ];
- } else {
- []
- }
- ]
+ show-html $html
}
method generate-index($db) {
@@ -62,7 +42,7 @@ method generate-index($db) {
.head(10)
.grep(!*.value.hidden)
.map(-> $pair {
- self.generate-blurb: $pair.key, $db
+ generate-blurb $pair.key, $db
});
my $head = generate-head($db.meta);
@@ -79,7 +59,7 @@ method generate-index($db) {
$body
];
- "$html"
+ show-html $html
}
method generate-archive($db) {
@@ -87,7 +67,7 @@ method generate-archive($db) {
$db.sorted-posts
.grep(!*.value.hidden)
.map(-> $pair {
- self.generate-blurb: $pair.key, $db
+ generate-blurb $pair.key, $db
});
my $head = generate-head($db.meta);
@@ -104,7 +84,7 @@ method generate-archive($db) {
$body
];
- "$html"
+ show-html $html
}
method generate-tag-blurb($db, $tag, $limit?) {
@@ -116,7 +96,7 @@ method generate-tag-blurb($db, $tag, $limit?) {
a :href($link), span [
h3 $post.title;
];
- post-info $post;
+ post-info $id, $post, $db;
if $desc ~~ Str:D {
div :class, [
p $post.description;
@@ -162,7 +142,7 @@ method generate-tags-page($db, @tags) {
$body
];
- "$html"
+ show-html $html
}
method generate-tag-page($db, $tag) {
@@ -178,5 +158,5 @@ method generate-tag-page($db, $tag) {
$body
];
- "$html"
+ show-html $html
}
diff --git a/lib/DB.rakumod b/lib/DB.rakumod
index dac655d..ec89780 100644
--- a/lib/DB.rakumod
+++ b/lib/DB.rakumod
@@ -9,10 +9,12 @@ use XML;
use XQ;
use DB::Post;
+use DB::Series;
use DB::BlogMeta;
use DB::MarkdownPost;
use DB::IdrisPost;
use DB::PlaceholderPost;
+use Render::Series;
use Atom;
use Config;
@@ -25,6 +27,8 @@ class PostDB {
#| A mapping from post ids to posts
# has %.posts is Posts;
has %.posts{Int} of PostTypes = %();
+ #| A mapping from series ids to series
+ has %.series{Int} of Series = %();
#| The post id to use for placeholder posts
has Int $.placeholder-id = 0;
@@ -37,6 +41,15 @@ class PostDB {
}
}
+ #| Get the next unused series ID
+ method next-series-id(--> Int) {
+ if %!series.elems > 0 {
+ %!series.keys.max + 1
+ } else {
+ 0
+ }
+ }
+
#| Insert a new post to the DB, returning its id
method insert-post(PostTypes $post --> Int) {
my $id = self.next-post-id;
@@ -44,6 +57,13 @@ class PostDB {
$id
}
+ #| Insert a new series to the DB, returning its id
+ method insert-series(Series:D $series --> Int) {
+ my $id = self.next-series-id;
+ %!series{$id} = $series;
+ $id
+ }
+
#| Initialize a new database
method init(BlogMeta:D $meta --> PostDB:D) {
my %posts{Int} of PostTypes = %();
@@ -57,9 +77,11 @@ class PostDB {
#| Write a database to a directory
method write(IO::Path:D $dir) {
my $posts-dir = $dir.add('posts/');
+ my $series-dir = $dir.add('series/');
# Make sure directory structrue exists
mkdir $dir unless $dir.e;
mkdir $posts-dir unless $posts-dir.e;
+ mkdir $series-dir unless $series-dir.e;
# Write out metadata
# TODO: Track changes and only write changed files
$dir.add('meta.json').spurt: $!meta.to-json(:sorted-keys);
@@ -67,6 +89,10 @@ class PostDB {
for %!posts.kv -> $key, $value {
$posts-dir.add("$key.json").spurt: $value.to-json(:sorted-keys);
}
+ # Write out the series
+ for %!series.kv -> $key, $value {
+ $series-dir.add("$key.json").spurt: $value.to-json(:sorted-keys);
+ }
}
#| Render the site to the provided output directory
@@ -87,7 +113,7 @@ class PostDB {
mkdir $by-slug unless $by-slug.e;
# Render all the posts and make symlinks
for %!posts.kv -> $id, $post {
- my $html = $config.generate-post: $post, $!meta;
+ my $html = $config.generate-post: $id, $post, self;
my $id-path = $by-id.add: "$id.html";
$id-path.spurt: $html;
for $post.all-slugs -> $slug {
@@ -114,6 +140,16 @@ class PostDB {
$tags-dir.add("$tag.html").spurt:
$config.generate-tag-page(self, $tag);
}
+ # Generate the series pages
+ my $series-dir = $out-dir.add('series/');
+ mkdir $series-dir unless $series-dir.e;
+ for %!series.kv -> $key, $value {
+ $series-dir.add("$key.html").spurt:
+ series-page($key, self);
+ }
+ # Generate the main series page
+ $out-dir.add('series.html').spurt:
+ series-list-page self;
# Render the rss/atom feed
my $atom-path = $out-dir.add('atom.xml');
my $atom = posts-to-atom self;
@@ -124,6 +160,7 @@ class PostDB {
$res-dir.add('colors.css').spurt: %?RESOURCES.slurp;
$res-dir.add('main.css').spurt: %?RESOURCES.slurp;
$res-dir.add('code.css').spurt: %?RESOURCES.slurp;
+ $res-dir.add('admonitions.css').spurt: %?RESOURCES.slurp;
}
#| Get a list of posts sorted by date
@@ -135,6 +172,7 @@ class PostDB {
#| Read the database out of a directory
sub read-db(IO::Path:D $dir --> PostDB:D) is export {
my $posts-dir = $dir.add('posts/');
+ my $series-dir = $dir.add('series/');
die "DB directory does not exist" unless $dir.e;
die "posts directory does not exist" unless $posts-dir.e;
# Read metadata
@@ -160,6 +198,16 @@ sub read-db(IO::Path:D $dir --> PostDB:D) is export {
}
}
}
+ # Read series
+ my %series{Int} of Series:D = %();
+ # For backwards compatability, the series directory is optional. It will be
+ # created on the next db operation, but we don't need it to read the site
+ if $series-dir.e {
+ for dir $series-dir -> $series {
+ my $id = $series.extension("").basename.Int;
+ %series{$id} = Series.from-json: $series.slurp;
+ }
+ }
# Build db structure
- PostDB.new: meta => $meta, posts => %posts
+ PostDB.new: meta => $meta, posts => %posts, series => %series
}
diff --git a/lib/DB/BlogMeta.rakumod b/lib/DB/BlogMeta.rakumod
index e6ccf5d..a5a1fd2 100644
--- a/lib/DB/BlogMeta.rakumod
+++ b/lib/DB/BlogMeta.rakumod
@@ -23,8 +23,8 @@ has Str:D $.base-url is required;
#| Return the base url, but substitute it out if the test environment variable
#| is set
method get-base-url(--> Str:D) {
- if %*ENV {
- "http://localhost:8080"
+ if %*ENV {
+ "http://localhost:8000"
} else {
$!base-url
}
diff --git a/lib/DB/Post.rakumod b/lib/DB/Post.rakumod
index 246753e..c1e98c7 100644
--- a/lib/DB/Post.rakumod
+++ b/lib/DB/Post.rakumod
@@ -37,7 +37,7 @@ DateTime:D @.edited-at
#| An optional list of extra slugs to use for this post
has Str:D @.slugs is json = [];
#| An optional list of tags for this post
-has Str:D @.tags is json = [];
+has Str:D @.tags is rw is json = [];
#| Should the post be hidden from the main list
has Bool:D $.hidden is json is rw = False;
diff --git a/lib/DB/Series.rakumod b/lib/DB/Series.rakumod
new file mode 100644
index 0000000..59fd964
--- /dev/null
+++ b/lib/DB/Series.rakumod
@@ -0,0 +1,35 @@
+use v6.e.PREVIEW;
+
+use JSON::Class:auth;
+
+#| A plain markdown post
+unit class Series is json(:pretty);
+
+#| The title of a series
+has Str:D $.title is required;
+
+#| The description of a series
+has Str:D $.desc is required;
+
+#| The ids of the posts in the series, in series order
+has Int:D @.post-ids is rw = [];
+
+#| Returns true if this series contains the given post id
+method contains-post(Int:D $post-id --> Bool:D) {
+ if $post-id ~~ any @!post-ids {
+ True
+ } else {
+ False
+ }
+}
+
+#| Returns the date of the lastest post
+method latest-post($db) {
+ my @posts = @!post-ids.map(-> $i {$db.posts{$i}});
+ if @posts {
+ my $most-recent-post = @posts.max(*.posted-at);
+ $most-recent-post.posted-at
+ } else {
+ Nil
+ }
+}
diff --git a/lib/Render/Head.rakumod b/lib/Render/Head.rakumod
index e7eb435..c93c3ff 100644
--- a/lib/Render/Head.rakumod
+++ b/lib/Render/Head.rakumod
@@ -38,6 +38,8 @@ sub generate-head(BlogMeta:D $meta, $title?, $description?) is export {
:href;
link :rel,
:href;
+ link :rel,
+ :href;
]
}
@@ -59,8 +61,9 @@ sub site-header(BlogMeta:D $meta) is export {
];
div :class, [
header-link 'Index', '/index.html', 'home';
- header-link 'Archive', '/archive.html', 'archive';
+ header-link 'All Posts', '/archive.html', 'archive';
header-link 'Tags', '/tags.html', 'purchase-tag-alt';
+ header-link 'Series', '/series.html', 'book';
header-link 'About', '/about.html', 'info-circle';
header-link 'Feed', '/atom.xml', 'rss';
];
diff --git a/lib/Render/Post.rakumod b/lib/Render/Post.rakumod
index 826582e..8bb6f97 100644
--- a/lib/Render/Post.rakumod
+++ b/lib/Render/Post.rakumod
@@ -3,6 +3,7 @@ unit module Render::Post;
use Render::Util;
use DB::Post;
+use DB::Series;
use HTML::Functional;
@@ -17,9 +18,9 @@ sub post-date(Post:D $post) is export {
);
div :class, :title("Posted At $timestamp"), [
- icon 'time';
+ icon 'calendar';
' ';
- $timestamp
+ $datetime.Date.Str
]
}
@@ -37,7 +38,7 @@ sub post-edit(Post:D $post) is export {
div :class, :title("Last Edited At $timestamp"), [
icon 'edit';
' ';
- $timestamp
+ $datetime.Date.Str
]
}
@@ -62,7 +63,7 @@ sub post-tag(Str:D $tag) is export {
span :class, [
a :href("/tags/$tag.html"), [
icon 'hash';
- $tag;
+ span $tag;
]
]
}
@@ -81,20 +82,73 @@ sub post-tags(Post:D $post) is export {
}
}
-sub post-info(Post:D $post) is export {
+sub series-tag(Int:D $post-id, Int:D $series-id, Series:D $series) is export {
+ span :class, [
+ a :href("/series/$series-id.html"), [
+ icon 'book';
+ ' ';
+ span :class, [
+ $series.title;
+ ' ';
+ '(';
+ ($series.post-ids.first($post-id, :k) + 1).Str;
+ '/';
+ $series.post-ids.elems.Str;
+ ')';
+ ]
+ ]
+ ]
+}
+
+sub post-series-tags(Int:D $post-id, Post:D $post, $db) is export {
+ # Find all the series this post is in
+ my @series = $db.series.grep(*.value.contains-post: $post-id);
+ if @series {
+ div :class,
+ @series.map(-> $pair {
+ series-tag $post-id, $pair.key, $pair.value
+ });
+ } else {
+ []
+ }
+}
+
+sub post-info(Int:D $id, Post:D $post, $db) is export {
div :class, [
post-date $post;
post-edit $post;
post-read-time $post;
post-tags $post;
+ post-series-tags $id, $post, $db;
];
}
-sub post-header(Post:D $post) is export {
+sub post-header(Int:D $id, Post:D $post, $db) is export {
header :class, [
div :class, [
h1 $post.title;
];
- post-info $post;
+ post-info $id, $post, $db;
+ ]
+}
+
+sub generate-blurb(Int:D $id, $db) is export {
+ my $post = $db.posts{$id};
+ my $desc = $post.description;
+ my $link = post-link $id, $post;
+ div :class, [
+ div :class, [
+ a :href($link), span [
+ h2 $post.title;
+ ];
+ ];
+ post-info $id, $post, $db;
+ if $desc ~~ Str:D {
+ div :class, [
+ p $post.description;
+ ];
+ } else {
+ []
+ }
]
}
diff --git a/lib/Render/Series.rakumod b/lib/Render/Series.rakumod
new file mode 100644
index 0000000..add11be
--- /dev/null
+++ b/lib/Render/Series.rakumod
@@ -0,0 +1,144 @@
+use v6.e.PREVIEW;
+unit module Render::Series;
+
+use Render::Util;
+use Render::Head;
+use Render::Post;
+use DB::Post;
+use DB::Series;
+
+use HTML::Functional;
+
+sub series-date(Series:D $series, $db) is export {
+ my $datetime = $series.latest-post: $db;
+ if $datetime {
+ my $timestamp = sprintf(
+ "%s %02d:%02d%s",
+ $datetime.yyyy-mm-dd,
+ ($datetime.hour % 12) || 12,
+ $datetime.minute,
+ $datetime.hour < 12 ?? 'am' !! 'pm'
+ );
+
+ div :class, :title("Latest post made at $timestamp"), [
+ icon 'calendar';
+ ' ';
+ $datetime.Date.Str
+ ]
+ } else {
+ []
+ }
+}
+
+sub series-read-time(Series:D $series, $db) is export {
+ my @readtimes = $series.post-ids.map(-> $i {$db.posts{$i}.readtimes});
+ my ($slow, $average, $fast) = 0, 0, 0;
+ for @readtimes -> ($s, $a, $f) {
+ $slow += $s;
+ $average += $a;
+ $fast += $f;
+ }
+ div :class, :title, [
+ icon 'timer';
+ ' ';
+ mins-to-string $slow;
+ ' ';
+ '/';
+ ' ';
+ mins-to-string $average;
+ ' ';
+ '/';
+ ' ';
+ mins-to-string $fast;
+ ]
+}
+
+sub series-count(Series:D $series, $db) is export {
+ my $count = $series.post-ids.elems;
+ div :class, :title("Series has $count articles"), [
+ icon 'add-to-queue';
+ ' ';
+ "$count articles";
+ ]
+}
+
+sub series-info(Series:D $series, $db) is export {
+ div :class, [
+ series-date $series, $db;
+ series-read-time $series, $db;
+ series-count $series, $db;
+ ]
+}
+
+sub series-header(Series:D $series, $db) is export {
+ header :class, [
+ div :class, [
+ h1 $series.title;
+ ];
+ div :class, [
+ p $series.desc;
+ ];
+ series-info $series, $db;
+ ]
+}
+
+sub series-page(Int:D $series-id, $db) is export {
+ my $meta = $db.meta;
+ my $series = $db.series{$series-id};
+ my $head = generate-head($meta, $series.title, $series.desc);
+ my $body =
+ body [
+ site-header $meta;
+ article :class, [
+ series-header $series, $db;
+ div :class,
+ $series.post-ids.map(*.&generate-blurb($db));
+ ]
+ ];
+
+ my $html = html :lang, [
+ $head;
+ $body
+ ];
+
+ show-html $html;
+}
+
+sub series-blurb(Int:D $id, Series:D $series, $db) {
+ my $link = "/series/$id.html";
+ div :class, [
+ div :class, [
+ a :href($link), span [
+ h2 $series.title;
+ ];
+ p $series.desc;
+ ];
+ series-info $series, $db;
+ ]
+}
+
+sub series-list-page($db) is export {
+ my @series = $db.series.sort(*.value.latest-post: $db);
+ my @series-blurbs = ();
+ for @series -> $pair {
+ my $id = $pair.key;
+ my $series = $pair.value;
+ @series-blurbs.push:
+ series-blurb $id, $series, $db;
+ }
+
+ my $head = generate-head($db.meta);
+ my $body = body [
+ site-header $db.meta;
+ div :class, [
+ h1 "All Series"
+ ], @series-blurbs;
+ ];
+
+ my $html = html :lang, [
+ $head;
+ $body;
+ ];
+
+ show-html $html;
+}
diff --git a/lib/Render/Util.rakumod b/lib/Render/Util.rakumod
index 05630a7..79ece58 100644
--- a/lib/Render/Util.rakumod
+++ b/lib/Render/Util.rakumod
@@ -5,6 +5,15 @@ use DB::Post;
use HTML::Functional;
+sub show-html($html) is export {
+ my $out = "$html";
+ # Work around HTML::Functional automatically putting newlines between tags
+ $out ~~ s:g/'' \v+ ''/<\/i>/;
+ $out ~~ s:g/\v+ ''/<\/a>/;
+ $out ~~ s:g/',' \v+ ' [!NOTE]
+> As this is a literate Idris document, and we haven't defined our `printf`
+> function yet, we have to use a `failing` block to ask the compiler to check
+> that this code parses, and syntax highlight it for us, but not attempt to
+> actually compile it.
+
+```idris
+failing
+ example_usage : String
+ example_usage = printf "%s %d %02d" "hello" 1 2
+```
+
+
+
+## Parsing a Format String
+
+## Calculating a Type From a Format String
+
+## printf
diff --git a/projects/Markdown/2025/01-Jan/AdventOfBugs.md b/projects/Markdown/2025/01-Jan/AdventOfBugs.md
index 3242738..121f50e 100644
--- a/projects/Markdown/2025/01-Jan/AdventOfBugs.md
+++ b/projects/Markdown/2025/01-Jan/AdventOfBugs.md
@@ -65,6 +65,10 @@ calls to make interaction between lazy and strict code Just Workâ˘:
had initially thought I had discovered just _one_ compiler bug, but it turns out
I also stumbled into an unrelated issue in the termination checker![^8]
+I also found a convoluted hole in the case generator that I had to reach out to
+the community for help minimizing (thank you
+dunhamsteve!):[idris-lang/Idris2#3466](https://github.com/idris-lang/Idris2/issues/3466)[^9]
+
I broke the [pack](https://github.com/stefan-hoeck/idris2-pack) package manager
by, apparently, being the first person to try and upload a library to `pack-db`
with library code in literate files, in my
@@ -96,3 +100,5 @@ ecosystem.
subset of the language for which termination is a 'trivial' property, which
includes most code I've written in the language so far, but not nearly all
of it.
+
+[^9]: It's me, I'm the user on the discord
diff --git a/resources/admonitions.css b/resources/admonitions.css
new file mode 100644
index 0000000..801cf4d
--- /dev/null
+++ b/resources/admonitions.css
@@ -0,0 +1,84 @@
+/* Universal configuration */
+.note,
+.tip,
+.important,
+.warning,
+.caution {
+ display: flex;
+ flex-direction: row;
+ width: 66%;
+ box-sizing: border-box;
+ background-color: var(--bg-1);
+ color: var(--fg-1);
+ padding: 0.5rem;
+ border-radius: 1rem;
+ border: solid 0.5rem;
+ margin-top: var(--box-margin-vert);
+ margin-bottom: var(--box-margin-vert);
+}
+.note .title,
+.tip .title,
+.important .title,
+.warning .title,
+.caution .title {
+ align-self: center;
+}
+.note .title p,
+.tip .title p,
+.important .title p,
+.warning .title p,
+.caution .title p {
+ font-size: 0;
+ display: inline-block;
+ position: relative;
+}
+.note .title p::before,
+.tip .title p::before,
+.important .title p::before,
+.warning .title p::before,
+.caution .title p::before {
+ font-family: 'boxicons' !important;
+ font-size: 3rem;
+ display: inline-block;
+}
+
+/* Notes */
+.note {
+ border-color: var(--blue);
+}
+.note .title p::before {
+ content: "\eb21";
+ color: var(--blue);
+}
+/* Tips */
+.tip {
+ border-color: var(--green);
+}
+.tip .title p::before {
+ content: "\eb0d";
+ color: var(--green);
+}
+/* Importants */
+.important {
+ border-color: var(--violet);
+}
+.important .title p::before {
+ content: "\eb0d";
+ color: var(--violet);
+}
+/* Warnings */
+.warning {
+ border-color: var(--orange);
+}
+.warning .title p::before {
+ content: "\e9a3";
+ color: var(--orange);
+}
+/* Cautions */
+.caution {
+ border-color: var(--red);
+}
+.caution .title p::before {
+ content: "\ee87";
+ color: var(--red);
+}
diff --git a/resources/colors.css b/resources/colors.css
index 5974f21..42db252 100644
--- a/resources/colors.css
+++ b/resources/colors.css
@@ -53,10 +53,11 @@ a:visited {
.site-tagline {
color: var(--dim-0);
}
-.post-body, .post-header, .post-blurbs, .tags, .tags .tag-blurb-post {
+.post-body, .post-header, .post-blurbs, .tags, .tags .tag-blurb-post,
+.series-header, .series-blurbs, .series-list {
background-color: var(--bg-0);
}
-.post-blurb, .tags .tag-blurb {
+.post-blurb, .tags .tag-blurb, .series-list-blurb {
background-color: var(--bg-1);
}
:not(.tags) .tag-blurb {
@@ -65,12 +66,15 @@ a:visited {
:not(.tags) .tag-blurb-post {
background-color: var(--bg-1);
}
-.post-title, .post-blurbs h1 {
+.post-title, .post-blurbs h1, .series-header h1, .series-list h1 {
color: var(--green);
}
.post-body h2, .post-body h3, .post-body h4 {
color: var(--fg-1);
}
+.post-info > *, .series-info > * {
+ background-color: var(--bg-2);
+}
blockquote {
background-color: var(--bg-1);
}
diff --git a/resources/main.css b/resources/main.css
index 4b54d5e..3617870 100644
--- a/resources/main.css
+++ b/resources/main.css
@@ -27,13 +27,13 @@
}
/* Main Body and Post Flexboxs */
-body, .post {
+body, .post, .series {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--box-gap);
}
-.post {
+.post, .series, .series-list {
width: 100%;
}
@@ -65,17 +65,17 @@ body, .post {
flex-wrap: wrap;
margin-top: var(--box-margin-vert);
}
-.header-links > a > span {
+.header-links > a > span, .post-series-tag > a > span, .post-tag > a > span {
text-decoration: underline;
}
-.header-links > a {
+.header-links > a, .post-series-tag > a, .post-tag > a {
text-decoration: none;
}
/* Style the post header, body, and blurbs */
/* TODO: Style footnotes and get footnote hover working */
-.post-header, .post-body {
+.post-header, .post-body, .series-header {
display: flex;
flex-direction: column;
align-items: center;
@@ -90,11 +90,11 @@ body, .post {
margin: auto var(--box-margin-horz);
align-self: stretch;
}
-.post-title h1 {
+.post-title h1, .series-title h1 {
margin-top: 0px;
margin-bottom: 0px;
}
-.post-info {
+.post-info, .series-info {
display: flex;
flex-direction: row;
align-items: center;
@@ -108,7 +108,7 @@ body, .post {
.post-body h2, .post-body h3, .post-body h4 {
text-align: center;
}
-.post-blurbs {
+.post-blurbs, .series-blurbs, .series-list {
display: flex;
flex-direction: column;
align-items: center;
@@ -116,8 +116,9 @@ body, .post {
max-width: var(--content-width);
padding: var(--box-padding-vert) var(--box-padding-horz);
border-radius: var(--box-radius);
+ box-sizing: border-box;
}
-.post-blurb {
+.post-blurb, .series-list-blurb {
width: 100%;
display: block;
border-radius: var(--box-radius);
@@ -128,6 +129,10 @@ body, .post {
flex-direction: column;
box-sizing: border-box;
}
+.post-info > *, .series-info > * {
+ padding: 0.25em;
+ border-radius: 0.25em;
+}
/* TODO: Nice fancy blockquotes */
blockquote {