Compare commits
13 commits
f8c575caac
...
b095fe356b
Author | SHA1 | Date | |
---|---|---|---|
b095fe356b | |||
88e355730b | |||
946ce908e9 | |||
19e06488c4 | |||
731d7aa19c | |||
87e18dbf60 | |||
72bd2a238c | |||
68e20af5d7 | |||
9a87e7825a | |||
07a9b29c00 | |||
2072f88711 | |||
20ecef3b3f | |||
f1515aab06 |
20 changed files with 836 additions and 217 deletions
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"title": "Stranger Systems",
|
||||
"placeholder-id": 0,
|
||||
"about-id": 2,
|
||||
"base-url": "https://www.stranger.systems",
|
||||
"placeholder-id": 0,
|
||||
"tagline": "Making software better by making it weird",
|
||||
"about-id": 2
|
||||
"title": "Stranger Systems"
|
||||
}
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"edited-at": [
|
||||
],
|
||||
"hidden": true,
|
||||
"placeholder": true,
|
||||
"posted-at": "2025-02-05T04:54:41.218425-05:00",
|
||||
"slugs": [
|
||||
],
|
||||
"source": "/dev/null",
|
||||
"edited-at": [
|
||||
],
|
||||
"tags": [
|
||||
],
|
||||
"posted-at": "2025-02-05T04:54:41.218425-05:00",
|
||||
"hidden": true,
|
||||
"placeholder": true
|
||||
]
|
||||
}
|
|
@ -1,12 +1,14 @@
|
|||
{
|
||||
"tags": [
|
||||
],
|
||||
"slugs": [
|
||||
],
|
||||
"edited-at": [
|
||||
],
|
||||
"posted-at": "2025-02-05T06:00:49.553777-05:00",
|
||||
"hidden": false,
|
||||
"markdown": true,
|
||||
"posted-at": "2025-02-05T06:00:49.553777-05:00",
|
||||
"slugs": [
|
||||
],
|
||||
"source": "/home/nathan/Projects/Blog/projects/Markdown/MyNewBlog.md",
|
||||
"markdown": true
|
||||
"tags": [
|
||||
"meta",
|
||||
"raku"
|
||||
]
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"posted-at": "2025-02-05T06:01:16.693698-05:00",
|
||||
"markdown": true,
|
||||
"edited-at": [
|
||||
],
|
||||
"hidden": true,
|
||||
"markdown": true,
|
||||
"posted-at": "2025-02-05T06:01:16.693698-05:00",
|
||||
"slugs": [
|
||||
],
|
||||
"hidden": true,
|
||||
"source": "/home/nathan/Projects/Blog/projects/Markdown/About.md",
|
||||
"tags": [
|
||||
]
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"hidden": false,
|
||||
"posted-at": "2021-11-29T00:00:00Z",
|
||||
"tags": [
|
||||
"cryptography"
|
||||
],
|
||||
"source": "/home/nathan/Projects/Blog/projects/Markdown/CryptoSuite.md",
|
||||
"edited-at": [
|
||||
],
|
||||
"hidden": false,
|
||||
"markdown": true,
|
||||
"posted-at": "2021-11-29T00:00:00Z",
|
||||
"slugs": [
|
||||
],
|
||||
"markdown": true
|
||||
"source": "/home/nathan/Projects/Blog/projects/Markdown/CryptoSuite.md",
|
||||
"tags": [
|
||||
"cryptography"
|
||||
]
|
||||
}
|
14
db/posts/4.json
Normal file
14
db/posts/4.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"edited-at": [
|
||||
],
|
||||
"hidden": false,
|
||||
"markdown": true,
|
||||
"posted-at": "2025-02-07T06:45:08.536663-05:00",
|
||||
"slugs": [
|
||||
],
|
||||
"source": "/home/nathan/Projects/Blog/projects/Markdown/2025/01-Jan/AdventOfBugs.md",
|
||||
"tags": [
|
||||
"idris",
|
||||
"advent-of-code"
|
||||
]
|
||||
}
|
|
@ -41,6 +41,12 @@ sub post-to-item(BlogMeta:D $meta, Int:D $id, Post:D $post --> XML::Element) {
|
|||
XML::Element.new(:name<link>, :attribs({:href($link), :rel<alternate>}));
|
||||
$xml.append:
|
||||
XML::Element.new(:name<summary>, :nodes([$desc])) if $desc;
|
||||
if $post.tags {
|
||||
for $post.tags -> $tag {
|
||||
$xml.append:
|
||||
XML::Element.new(:name<category>, :attribs({:term($tag)}));
|
||||
}
|
||||
}
|
||||
|
||||
$xml
|
||||
}
|
||||
|
|
|
@ -1,181 +1,24 @@
|
|||
use v6.e.PREVIEW;
|
||||
|
||||
use HTML::Functional;
|
||||
|
||||
use Render::Util;
|
||||
use Render::Head;
|
||||
use Render::Post;
|
||||
use DB::BlogMeta;
|
||||
use DB::Post;
|
||||
|
||||
unit class Config;
|
||||
|
||||
method generate-head($title, BlogMeta:D $meta, $description?) {
|
||||
head [
|
||||
meta :charset<utf-8>;
|
||||
meta :name<viewport>, :content<width=device-width, initial-scale=1>;
|
||||
meta :author :content<Nathan McCarty>;
|
||||
do if $title ~~ Str:D {
|
||||
title "$title — {$meta.title}";
|
||||
} else {
|
||||
title $meta.title;
|
||||
}
|
||||
# Add description, if one exists
|
||||
do if $description ~~ Str:D {
|
||||
meta :description :content($description)
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
# Preconnect to all our resource sources
|
||||
link :rel<preconnect> :href<https://static.stranger.systems>;
|
||||
link :rel<preconnect> :href<https://fonts.googleapis.com>;
|
||||
link :rel<preconnect> :href<https://fonts.gstatic.com> :crossorigin;
|
||||
link :rel<preconnect> :href<https://unpkg.com>;
|
||||
# Load fonts, Iosevka for code, Open Sans for content, and boxicons for
|
||||
# icons
|
||||
link :rel<stylesheet>,
|
||||
:href<https://static.stranger.systems/fonts/Iosevka/Iosevka.css>;
|
||||
link :rel<stylesheet>,
|
||||
:href<https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap>;
|
||||
link :rel<stylesheet>,
|
||||
:href<https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css>;
|
||||
# Link our style sheets
|
||||
link :rel<stylesheet>,
|
||||
:href</resources/colors.css>;
|
||||
link :rel<stylesheet>,
|
||||
:href</resources/main.css>;
|
||||
link :rel<stylesheet>,
|
||||
:href</resources/code.css>;
|
||||
];
|
||||
}
|
||||
|
||||
method site-header(BlogMeta:D $meta) {
|
||||
header :class<site-header>, [
|
||||
div :class<site-logo>, [
|
||||
# TODO: Use a real image here
|
||||
$meta.title
|
||||
];
|
||||
div :class<site-tagline>, [
|
||||
$meta.tagline
|
||||
];
|
||||
div :class<header-links>, [
|
||||
a :href</index.html>, [
|
||||
icon 'home';
|
||||
' ';
|
||||
span [
|
||||
'Home';
|
||||
];
|
||||
];
|
||||
a :href</archive.html>, [
|
||||
icon 'archive';
|
||||
' ';
|
||||
span [
|
||||
'Archive';
|
||||
];
|
||||
];
|
||||
a :href</about.html>, [
|
||||
icon 'info-circle';
|
||||
' ';
|
||||
span [
|
||||
'About';
|
||||
];
|
||||
];
|
||||
a :href</atom.xml>, [
|
||||
icon 'rss';
|
||||
' ';
|
||||
span [
|
||||
'Feed';
|
||||
];
|
||||
];
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
method post-date(Post:D $post) {
|
||||
my $datetime = $post.posted-at;
|
||||
my $timestamp = sprintf(
|
||||
"%s %02d:%02d%s",
|
||||
$datetime.yyyy-mm-dd,
|
||||
($datetime.hour % 12) || 12,
|
||||
$datetime.minute,
|
||||
$datetime.hour < 12 ?? 'am' !! 'pm'
|
||||
);
|
||||
|
||||
div :class<post-time>, :title("Posted At $timestamp"), [
|
||||
icon 'time';
|
||||
' ';
|
||||
$timestamp
|
||||
]
|
||||
}
|
||||
|
||||
method post-edit(Post:D $post) {
|
||||
return [] unless $post.edited-at.elems;
|
||||
my $datetime = $post.edited-at.max;
|
||||
my $timestamp = sprintf(
|
||||
"%s %02d:%02d%s",
|
||||
$datetime.yyyy-mm-dd,
|
||||
($datetime.hour % 12) || 12,
|
||||
$datetime.minute,
|
||||
$datetime.hour < 12 ?? 'am' !! 'pm'
|
||||
);
|
||||
|
||||
div :class<post-edit>, :title("Laste Edited At $timestamp"), [
|
||||
icon 'edit';
|
||||
' ';
|
||||
$timestamp
|
||||
]
|
||||
}
|
||||
|
||||
sub mins-to-string($mins) {
|
||||
if $mins < 60 {
|
||||
$mins.Str ~ "m"
|
||||
} else {
|
||||
my $h = $mins div 60;
|
||||
my $m = $mins mod 60;
|
||||
$h.Str ~ "h" ~ $m.Str ~ "m"
|
||||
}
|
||||
}
|
||||
|
||||
method post-read-time(Post:D $post) {
|
||||
my ($slow, $average, $fast) = $post.readtimes;
|
||||
div :class<post-read-time>, :title<Estimated read time at 140/180/220 WPM>, [
|
||||
icon 'timer';
|
||||
' ';
|
||||
mins-to-string $slow;
|
||||
' ';
|
||||
'/';
|
||||
' ';
|
||||
mins-to-string $average;
|
||||
' ';
|
||||
'/';
|
||||
' ';
|
||||
mins-to-string $fast;
|
||||
]
|
||||
}
|
||||
|
||||
method post-info(Post:D $post) {
|
||||
div :class<post-info>, [
|
||||
self.post-date: $post;
|
||||
self.post-edit: $post;
|
||||
self.post-read-time: $post;
|
||||
# TODO: Add tags once we have support for that
|
||||
];
|
||||
}
|
||||
|
||||
method post-header(Post:D $post) {
|
||||
header :class<post-header>, [
|
||||
div :class<post-title>, [
|
||||
h1 $post.title;
|
||||
];
|
||||
self.post-info: $post;
|
||||
]
|
||||
}
|
||||
|
||||
# TODO: Support GFM admonitions
|
||||
method generate-post(Post:D $post, BlogMeta:D $meta) {
|
||||
my $content = $post.render-html;
|
||||
my $head = self.generate-head($post.title, $meta, $post.description);
|
||||
my $head = generate-head($meta, $post.title, $post.description);
|
||||
my $body =
|
||||
body [
|
||||
self.site-header: $meta;
|
||||
site-header $meta;
|
||||
article :class<post>, [
|
||||
self.post-header: $post;
|
||||
post-header $post;
|
||||
div :class<post-body>, [
|
||||
$content;
|
||||
]
|
||||
|
@ -195,20 +38,14 @@ method generate-post(Post:D $post, BlogMeta:D $meta) {
|
|||
method generate-blurb(Int:D $id, $db) {
|
||||
my $post = $db.posts{$id};
|
||||
my $desc = $post.description;
|
||||
my @slugs = $post.all-slugs;
|
||||
# Use the primary slug if there is one, the id if there isn't
|
||||
my $link = do if @slugs.elems {
|
||||
"/posts/by-slug/{@slugs[*-1]}.html"
|
||||
} else {
|
||||
"/posts/by-id/$id.html"
|
||||
}
|
||||
my $link = post-link $id, $post;
|
||||
div :class<post-blurb>, [
|
||||
div :class<post-blurb-title>, [
|
||||
a :href($link), span [
|
||||
h2 $post.title;
|
||||
];
|
||||
];
|
||||
self.post-info: $post;
|
||||
post-info $post;
|
||||
if $desc ~~ Str:D {
|
||||
div :class<post-blurb-description>, [
|
||||
p $post.description;
|
||||
|
@ -228,9 +65,9 @@ method generate-index($db) {
|
|||
self.generate-blurb: $pair.key, $db
|
||||
});
|
||||
|
||||
my $head = self.generate-head(Nil, $db.meta);
|
||||
my $head = generate-head($db.meta);
|
||||
my $body = body [
|
||||
self.site-header: $db.meta;
|
||||
site-header $db.meta;
|
||||
div :class<post-blurbs>, [
|
||||
h1 "Recent Posts"
|
||||
], @most-recent;
|
||||
|
@ -253,9 +90,9 @@ method generate-archive($db) {
|
|||
self.generate-blurb: $pair.key, $db
|
||||
});
|
||||
|
||||
my $head = self.generate-head(Nil, $db.meta);
|
||||
my $head = generate-head($db.meta);
|
||||
my $body = body [
|
||||
self.site-header: $db.meta;
|
||||
site-header $db.meta;
|
||||
div :class<post-blurbs>, [
|
||||
h1 "All Posts"
|
||||
], @most-recent;
|
||||
|
@ -270,6 +107,76 @@ method generate-archive($db) {
|
|||
"<!doctype html>$html"
|
||||
}
|
||||
|
||||
sub icon($icon) {
|
||||
i(:class("bx bx-$icon"))
|
||||
method generate-tag-blurb($db, $tag, $limit?) {
|
||||
sub post-to-link($id, $post) {
|
||||
my $desc = $post.description;
|
||||
my $link = post-link $id, $post;
|
||||
div :class<tag-blurb-post>, [
|
||||
div :class<tag-blurb-post-title>, [
|
||||
a :href($link), span [
|
||||
h3 $post.title;
|
||||
];
|
||||
post-info $post;
|
||||
if $desc ~~ Str:D {
|
||||
div :class<tag-blurb-post-description>, [
|
||||
p $post.description;
|
||||
];
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
];
|
||||
]
|
||||
}
|
||||
my @posts = $db.sorted-posts.grep(-> $a { $tag (elem) $a.value.tags });
|
||||
if $limit {
|
||||
@posts.=head($limit);
|
||||
}
|
||||
if @posts {
|
||||
div :class<tag-blurb>, [
|
||||
span :class<tag-blurb-title>, [
|
||||
a :href("/tags/$tag.html"), [
|
||||
icon 'hash';
|
||||
$tag;
|
||||
];
|
||||
];
|
||||
div :class<tag-blurb-links>,
|
||||
@posts.map(-> $a {post-to-link $a.key, $a.value});
|
||||
]
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
method generate-tags-page($db, @tags) {
|
||||
my $head = generate-head($db.meta);
|
||||
my $body = body [
|
||||
site-header $db.meta;
|
||||
div :class<tags>, [
|
||||
h1 "Tags";
|
||||
], @tags.map(-> $tag {self.generate-tag-blurb($db, $tag, 4)});
|
||||
];
|
||||
|
||||
my $html =
|
||||
html :lang<en>, [
|
||||
$head,
|
||||
$body
|
||||
];
|
||||
|
||||
"<!doctype html>$html"
|
||||
}
|
||||
|
||||
method generate-tag-page($db, $tag) {
|
||||
my $head = generate-head($db.meta);
|
||||
my $body = body [
|
||||
site-header $db.meta;
|
||||
self.generate-tag-blurb($db, $tag, 4);
|
||||
];
|
||||
|
||||
my $html =
|
||||
html :lang<en>, [
|
||||
$head,
|
||||
$body
|
||||
];
|
||||
|
||||
"<!doctype html>$html"
|
||||
}
|
||||
|
|
|
@ -62,10 +62,10 @@ class PostDB {
|
|||
mkdir $posts-dir unless $posts-dir.e;
|
||||
# Write out metadata
|
||||
# TODO: Track changes and only write changed files
|
||||
$dir.add('meta.json').spurt: $!meta.to-json;
|
||||
$dir.add('meta.json').spurt: $!meta.to-json(:sorted-keys);
|
||||
# Write out posts (ids are the filename)
|
||||
for %!posts.kv -> $key, $value {
|
||||
$posts-dir.add("$key.json").spurt: $value.to-json;
|
||||
$posts-dir.add("$key.json").spurt: $value.to-json(:sorted-keys);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -105,6 +105,15 @@ class PostDB {
|
|||
my $about-path = $out-dir.add('about.html');
|
||||
$about-path.unlink if $about-path.l;
|
||||
$by-id.add("{$!meta.about-id}.html").symlink: $about-path;
|
||||
# Generate the tags pages
|
||||
my @tags = %!posts.values.map(*.tags).flat.unique.sort;
|
||||
$out-dir.add('tags.html').spurt: $config.generate-tags-page(self, @tags);
|
||||
my $tags-dir = $out-dir.add('tags/');
|
||||
mkdir $tags-dir unless $tags-dir.e;
|
||||
for @tags -> $tag {
|
||||
$tags-dir.add("$tag.html").spurt:
|
||||
$config.generate-tag-page(self, $tag);
|
||||
}
|
||||
# Render the rss/atom feed
|
||||
my $atom-path = $out-dir.add('atom.xml');
|
||||
my $atom = posts-to-atom self;
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
use v6.e.PREVIEW;
|
||||
|
||||
use Pandoc;
|
||||
use JSON::Class:auth<zef:vrurg>;
|
||||
use Pygments;
|
||||
use DB::Post;
|
||||
|
||||
|
||||
use JSON::Class:auth<zef:vrurg>;
|
||||
use File::Temp;
|
||||
|
||||
#| A plain markdown post
|
||||
unit class MarkdownPost does Post is json(:pretty);
|
||||
|
||||
|
@ -20,7 +24,17 @@ method title(--> Str:D) {
|
|||
|
||||
# Simply provide our source file to pandoc
|
||||
method render-html(--> Str:D) {
|
||||
markdown-to-html $!source
|
||||
# Test to see if this posts contains any fenced code blocks, if so,
|
||||
# pygmentize it through a temporary file
|
||||
my $contents = $!source.slurp;
|
||||
if $contents ~~ /'```'/ {
|
||||
my $output = highlight-code $contents;
|
||||
my ($filename, $filehandle) = tempfile;
|
||||
$filehandle.spurt: $output, :close;
|
||||
markdown-to-html $filename.IO
|
||||
} else {
|
||||
markdown-to-html $!source
|
||||
}
|
||||
}
|
||||
|
||||
# Return our summary, if we have one, otherwise extract the first paragraph of
|
||||
|
|
|
@ -6,7 +6,7 @@ use JSON::Fast;
|
|||
#| Run pandoc with the given arguments, dieing on failure
|
||||
sub pandoc(*@args --> Str:D) {
|
||||
# Call into pandoc
|
||||
my $pandoc = run 'pandoc', @args, :out, :err;
|
||||
my $pandoc = run 'pandoc', '--no-highlight', @args, :out, :err;
|
||||
|
||||
# Collect the output
|
||||
my $output = $pandoc.out.slurp: :close;
|
||||
|
@ -88,6 +88,7 @@ sub markdown-first-paragraph(IO::Path:D $file --> Str:D) is export {
|
|||
$para ~= "\n";
|
||||
}
|
||||
when "Link" {
|
||||
# TODO: Properly descend into links
|
||||
$para ~= $component<c>[1][0]<c>;
|
||||
}
|
||||
default {
|
||||
|
|
40
lib/Pygments.rakumod
Normal file
40
lib/Pygments.rakumod
Normal file
|
@ -0,0 +1,40 @@
|
|||
#| Interaction with pygments
|
||||
unit module Pygments;
|
||||
|
||||
my token fence { '```' }
|
||||
my token info-string { \w+ }
|
||||
# TODO: Be more precise about this so we can handle backticks in code blocks
|
||||
my token code { <-[`]>+ }
|
||||
my token code-block {
|
||||
<&fence> <info-string> \h* \v
|
||||
<code>
|
||||
<&fence>
|
||||
}
|
||||
|
||||
sub pygment(Str:D $code, Str:D $lang --> Str:D) {
|
||||
my $pygments = run <pygmentize -f html -l>, $lang, :out, :in;
|
||||
$pygments.in.spurt: $code, :close;
|
||||
$pygments.out.slurp: :close;
|
||||
}
|
||||
|
||||
sub highlight-code(Str:D $input --> Str:D) is export {
|
||||
my $text = $input;
|
||||
# TODO: Figure out a way to exclude idris code so we can process both in the
|
||||
# same file
|
||||
while $text ~~ &code-block {
|
||||
my $match = $/;
|
||||
# Extract the match and have pygments colorize the code
|
||||
my $code = $match<code>.Str;
|
||||
my $lang = $match<info-string>.Str;
|
||||
my $out = pygment $code, $lang;
|
||||
## Mangle the html to meet our needs
|
||||
# Delete the existing div and construct a <code></code> inside the <pre>
|
||||
$out ~~ s:g/'<' \/? 'div' <-[>]>* '>'//;
|
||||
$out ~~ s:g/'<pre>'/<pre><code class="{$lang}-code">/;
|
||||
$out ~~ s:g/'</pre>'/<\/code><\/pre>/;
|
||||
# Rename the classes to match our needs
|
||||
$out ~~ s:g/'span class="' (\w+) '"'/span class="hl-{$/[0].lc}"/;
|
||||
$text = $match.replace-with: $out;
|
||||
}
|
||||
$text
|
||||
}
|
68
lib/Render/Head.rakumod
Normal file
68
lib/Render/Head.rakumod
Normal file
|
@ -0,0 +1,68 @@
|
|||
use v6.e.PREVIEW;
|
||||
unit module Render::Head;
|
||||
|
||||
use HTML::Functional;
|
||||
|
||||
use Render::Util;
|
||||
use DB::BlogMeta;
|
||||
|
||||
sub generate-head(BlogMeta:D $meta, $title?, $description?) is export {
|
||||
head [
|
||||
meta :charset<utf-8>;
|
||||
meta :name<viewport>, :content<width=device-width, initial-scale=1>;
|
||||
meta :author :content<Nathan McCarty>;
|
||||
do if $title ~~ Str:D {
|
||||
title "$title — {$meta.title}";
|
||||
} else {
|
||||
title $meta.title;
|
||||
}
|
||||
# Add description, if one exists
|
||||
optl $description ~~ Str:D, -> {meta :description :content($description)};
|
||||
# Preconnect to all our resource sources
|
||||
link :rel<preconnect> :href<https://static.stranger.systems>;
|
||||
link :rel<preconnect> :href<https://fonts.googleapis.com>;
|
||||
link :rel<preconnect> :href<https://fonts.gstatic.com> :crossorigin;
|
||||
link :rel<preconnect> :href<https://unpkg.com>;
|
||||
# Load fonts, Iosevka for code, Open Sans for content, and boxicons for
|
||||
# icons
|
||||
link :rel<stylesheet>,
|
||||
:href<https://static.stranger.systems/fonts/Iosevka/Iosevka.css>;
|
||||
link :rel<stylesheet>,
|
||||
:href<https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap>;
|
||||
link :rel<stylesheet>,
|
||||
:href<https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css>;
|
||||
# Link our style sheets
|
||||
link :rel<stylesheet>,
|
||||
:href</resources/colors.css>;
|
||||
link :rel<stylesheet>,
|
||||
:href</resources/main.css>;
|
||||
link :rel<stylesheet>,
|
||||
:href</resources/code.css>;
|
||||
]
|
||||
}
|
||||
|
||||
sub site-header(BlogMeta:D $meta) is export {
|
||||
sub header-link($name, $path, $icon) {
|
||||
a :href("$path"), [
|
||||
icon $icon;
|
||||
' ';
|
||||
span $name;
|
||||
]
|
||||
}
|
||||
header :class<site-header>, [
|
||||
div :class<site-logo>, [
|
||||
# TODO: Use a real image here
|
||||
$meta.title
|
||||
];
|
||||
div :class<site-tagline>, [
|
||||
$meta.tagline
|
||||
];
|
||||
div :class<header-links>, [
|
||||
header-link 'Index', '/index.html', 'home';
|
||||
header-link 'Archive', '/archive.html', 'archive';
|
||||
header-link 'Tags', '/tags.html', 'purchase-tag-alt';
|
||||
header-link 'About', '/about.html', 'info-circle';
|
||||
header-link 'Feed', '/atom.xml', 'rss';
|
||||
];
|
||||
]
|
||||
}
|
100
lib/Render/Post.rakumod
Normal file
100
lib/Render/Post.rakumod
Normal file
|
@ -0,0 +1,100 @@
|
|||
use v6.e.PREVIEW;
|
||||
unit module Render::Post;
|
||||
|
||||
use Render::Util;
|
||||
use DB::Post;
|
||||
|
||||
use HTML::Functional;
|
||||
|
||||
sub post-date(Post:D $post) is export {
|
||||
my $datetime = $post.posted-at;
|
||||
my $timestamp = sprintf(
|
||||
"%s %02d:%02d%s",
|
||||
$datetime.yyyy-mm-dd,
|
||||
($datetime.hour % 12) || 12,
|
||||
$datetime.minute,
|
||||
$datetime.hour < 12 ?? 'am' !! 'pm'
|
||||
);
|
||||
|
||||
div :class<post-time>, :title("Posted At $timestamp"), [
|
||||
icon 'time';
|
||||
' ';
|
||||
$timestamp
|
||||
]
|
||||
}
|
||||
|
||||
sub post-edit(Post:D $post) is export {
|
||||
return [] unless $post.edited-at.elems;
|
||||
my $datetime = $post.edited-at.max;
|
||||
my $timestamp = sprintf(
|
||||
"%s %02d:%02d%s",
|
||||
$datetime.yyyy-mm-dd,
|
||||
($datetime.hour % 12) || 12,
|
||||
$datetime.minute,
|
||||
$datetime.hour < 12 ?? 'am' !! 'pm'
|
||||
);
|
||||
|
||||
div :class<post-edit>, :title("Last Edited At $timestamp"), [
|
||||
icon 'edit';
|
||||
' ';
|
||||
$timestamp
|
||||
]
|
||||
}
|
||||
|
||||
sub post-read-time(Post:D $post) is export {
|
||||
my ($slow, $average, $fast) = $post.readtimes;
|
||||
div :class<post-read-time>, :title<Estimated read time at 140/180/220 WPM>, [
|
||||
icon 'timer';
|
||||
' ';
|
||||
mins-to-string $slow;
|
||||
' ';
|
||||
'/';
|
||||
' ';
|
||||
mins-to-string $average;
|
||||
' ';
|
||||
'/';
|
||||
' ';
|
||||
mins-to-string $fast;
|
||||
]
|
||||
}
|
||||
|
||||
sub post-tag(Str:D $tag) is export {
|
||||
span :class<post-tag>, [
|
||||
a :href("/tags/$tag.html"), [
|
||||
icon 'hash';
|
||||
$tag;
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
sub post-tags(Post:D $post) is export {
|
||||
my @tags = $post.tags.sort;
|
||||
if @tags {
|
||||
@tags.=map(*.&post-tag);
|
||||
div :class<post-tags>, [
|
||||
icon 'purchase-tag-alt';
|
||||
' ';
|
||||
intersperse(', ', @tags);
|
||||
]
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
sub post-info(Post:D $post) is export {
|
||||
div :class<post-info>, [
|
||||
post-date $post;
|
||||
post-edit $post;
|
||||
post-read-time $post;
|
||||
post-tags $post;
|
||||
];
|
||||
}
|
||||
|
||||
sub post-header(Post:D $post) is export {
|
||||
header :class<post-header>, [
|
||||
div :class<post-title>, [
|
||||
h1 $post.title;
|
||||
];
|
||||
post-info $post;
|
||||
]
|
||||
}
|
54
lib/Render/Util.rakumod
Normal file
54
lib/Render/Util.rakumod
Normal file
|
@ -0,0 +1,54 @@
|
|||
use v6.e.PREVIEW;
|
||||
unit module Render::Util;
|
||||
|
||||
use DB::Post;
|
||||
|
||||
use HTML::Functional;
|
||||
|
||||
sub opt($test, $item) is export {
|
||||
if $test {
|
||||
$item
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
sub optl($test, &item) is export {
|
||||
if $test {
|
||||
item
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
#| Link to the post by the primary slug, if there is one, linking to it by id
|
||||
#| otherwise
|
||||
sub post-link(Int:D $id, Post:D $post --> Str:D) is export {
|
||||
my @slugs = $post.all-slugs;
|
||||
if @slugs {
|
||||
"/posts/by-slug/{@slugs[*-1]}.html"
|
||||
} else {
|
||||
"/posts/by-id/$id.html"
|
||||
}
|
||||
}
|
||||
|
||||
sub icon($icon) is export {
|
||||
i(:class("bx bx-$icon"))
|
||||
}
|
||||
|
||||
sub mins-to-string($mins) is export {
|
||||
if $mins < 60 {
|
||||
$mins.Str ~ "m"
|
||||
} else {
|
||||
my $h = $mins div 60;
|
||||
my $m = $mins mod 60;
|
||||
$h.Str ~ "h" ~ $m.Str ~ "m"
|
||||
}
|
||||
}
|
||||
|
||||
sub intersperse (\element, +list) is export {
|
||||
gather for list {
|
||||
FIRST .take, next;
|
||||
take slip element, $_;
|
||||
}
|
||||
}
|
98
projects/Markdown/2025/01-Jan/AdventOfBugs.md
Normal file
98
projects/Markdown/2025/01-Jan/AdventOfBugs.md
Normal file
|
@ -0,0 +1,98 @@
|
|||
# Advent of bugs
|
||||
|
||||
Near the start of january, after bumping into some ecosystem issues and lack of
|
||||
a personal support library while working on the 2024
|
||||
[Advent of Code](https://adventofcode.com/) in Idris, I started working on a
|
||||
project to solve all of the Advent of Code problems from all the years in a
|
||||
single massive entirely literate Idris project and publish it as an mdbook. I'm
|
||||
calling it
|
||||
[Idris 2 by Extremely Contrived](https://static.stranger.systems/idris-by-contrived-example/)
|
||||
example.
|
||||
|
||||
## The Good
|
||||
|
||||
This has been an amazingly fun project so far. AoC problems are nice and bite
|
||||
sized, giving really good material to work with for incrementally introducing
|
||||
more and more complex concepts. I have been following a sort of "weirdness
|
||||
budget" for each day's solutions, letting myself build on the already
|
||||
established weirdness from previous days. So far I'm 13 days in, and I've
|
||||
already had excuses to introduce all sorts of fun concepts, effects, dependent
|
||||
pattern matching, indexed type families, and refinement types, just to name a
|
||||
few.
|
||||
|
||||
Functional programming languages are a lot of fun to model puzzle problems in to
|
||||
start with, but the new design space afforded by dependent typing offers a new,
|
||||
wonderfully fun challenge of figuring out just what to show off while I'm
|
||||
solving the puzzle. There have already been a few problems that I've wound up
|
||||
spending a few days on just tweaking and refining until I was pleased with the
|
||||
balance between weirdness expenditure and showing off what I want to show off in
|
||||
a reasonably approachable way.
|
||||
|
||||
A couple of personal favorites of mine so far have been the
|
||||
[JSON parser](https://static.stranger.systems/idris-by-contrived-example/Parser/JSON.html),
|
||||
and the
|
||||
[`Digits` views](https://static.stranger.systems/idris-by-contrived-example/Util/Eff.html).
|
||||
The JSON parser is written in a bespoke mini-library for doing effectively
|
||||
parsing that I created just for this project, and refining the library to
|
||||
optimize for readability of the parsers written in it was an absolute joy. The
|
||||
digits views allowing pattern matching on normal integers as if they were lists
|
||||
of digits was less fun to write[^1], they are amazingly fun to use.
|
||||
|
||||
## The Bad
|
||||
|
||||
I've had to write a lot of support code already. I can't really fault the
|
||||
language, its a pretty new language in its pre-1.0 stage, in a pretty niche
|
||||
area, dependent types are still quite scary to the majority of programmers, and
|
||||
ecosystem improvement was a major goal of the project going in. I have already
|
||||
had to write several "basic" data structures, and there's no sign the need to do
|
||||
so will let up anytime soon[^6].
|
||||
|
||||
Honestly the most painful part has been the lack of support for Idris in
|
||||
external tooling. Essentially every syntax highlighting library has _completely
|
||||
ass_ support for Idris. I managed to eventually sneak proper semantic
|
||||
highlighting into mdbook, it even plays nice with mdbook themes, but I had to
|
||||
commit horrible, _horrible_ crimes to get this working[^2].
|
||||
|
||||
## The Ugly
|
||||
|
||||
This isn't exactly a complaint, after all, ecosystem improvement was a goal
|
||||
after all, but I've already run into 2 or 3 compiler bugs, a bug in the `pack`
|
||||
package manager, and a weird behavior in katla that's a _maybe_ bug.
|
||||
|
||||
I busted the part of the compiler that automatically inserts `force` and `delay`
|
||||
calls to make interaction between lazy and strict code Just Work™:
|
||||
[idris-lang/Idris2#3461](https://github.com/idris-lang/Idris2/issues/3461). I
|
||||
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 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
|
||||
[Structures](https://git.sr.ht/~thatonelutenist/Structures) package, checking in
|
||||
a new data structure motivated by my advent project, completely breaking the
|
||||
automated docs generation:
|
||||
[stefan-hoeck/idris2-pack!319](https://github.com/stefan-hoeck/idris2-pack/pull/319).
|
||||
This one was especially wild to me, with how popular literate programs are in
|
||||
the community, I would have never expected to be the first person to try this.
|
||||
|
||||
## Looking Forward
|
||||
|
||||
Despite the challenges, this has been a lovely experience so far. I greatly look
|
||||
forward to pressing through to completion, whatever it may bring, and have a
|
||||
trophy case full of bugs identified/fixed and new libraries to bring to the
|
||||
ecosystem.
|
||||
|
||||
[^1]: While perfectly understandable, it's not reasonable to expect the compiler
|
||||
to be able to reason about primitive "machine" integers like this on its
|
||||
own, needing to resort to so much `believe_me` on this one did make me a bit
|
||||
sad
|
||||
|
||||
[^6]: Well well well, if it isn't the consequences of my actions
|
||||
|
||||
[^2]: Do not look at the `build-book` script if you value your sanity
|
||||
|
||||
[^8]: Before anyone gets upset here, despite the name, the termination checker
|
||||
doesn't actually solve the halting problem. It only automatically accepts a
|
||||
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.
|
205
projects/Markdown/RustPosting.md
Normal file
205
projects/Markdown/RustPosting.md
Normal file
|
@ -0,0 +1,205 @@
|
|||
# Rustposting
|
||||
|
||||
Some example code with some potential problem characters:
|
||||
|
||||
```rust
|
||||
let newline_string = "hello \n world";
|
||||
let thing = *newline_string;
|
||||
```
|
||||
|
||||
Here is some example rust code:
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
// Statements here are executed when the compiled binary is called.
|
||||
|
||||
// Print text to the console
|
||||
println!("Hello World!");
|
||||
}
|
||||
```
|
||||
|
||||
And a slightly less trivial example:
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
// Variables can be type annotated.
|
||||
let logical: bool = true;
|
||||
|
||||
let a_float: f64 = 1.0; // Regular annotation
|
||||
let an_integer = 5i32; // Suffix annotation
|
||||
|
||||
// Or a default will be used.
|
||||
let default_float = 3.0; //
|
||||
let default_integer = 7; //
|
||||
|
||||
// A type can also be inferred from context.
|
||||
let mut inferred_type = 12; // Type i64 is inferred from another line.
|
||||
inferred_type = 4294967296i64;
|
||||
|
||||
// A mutable variable's value can be changed.
|
||||
let mut mutable = 12; // Mutable
|
||||
mutable = 21;
|
||||
|
||||
// Error! The type of a variable can't be changed.
|
||||
mutable = true;
|
||||
|
||||
// Variables can be overwritten with shadowing.
|
||||
let mutable = true;
|
||||
|
||||
/* Compound types - Array and Tuple */
|
||||
|
||||
// Array signature consists of Type T and length as [T; length].
|
||||
let my_array: [i32; 5] = [1, 2, 3, 4, 5];
|
||||
|
||||
// Tuple is a collection of values of different types
|
||||
// and is constructed using parentheses ().
|
||||
let my_tuple = (5u32, 1u8, true, -5.04f32);
|
||||
}
|
||||
```
|
||||
|
||||
Toss in some type definitions to
|
||||
|
||||
```rust
|
||||
#[derive(Debug)]
|
||||
struct Person {
|
||||
name: String,
|
||||
age: u8,
|
||||
}
|
||||
|
||||
// A unit struct
|
||||
struct Unit;
|
||||
|
||||
// A tuple struct
|
||||
struct Pair(i32, f32);
|
||||
|
||||
enum WebEvent {
|
||||
// An variant may either be
|
||||
PageLoad,
|
||||
PageUnload,
|
||||
// like tuple structs,
|
||||
KeyPress(char),
|
||||
Paste(String),
|
||||
// or c-like structures.
|
||||
Click { x: i64, y: i64 },
|
||||
}
|
||||
|
||||
struct Point {
|
||||
x: f64,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
// Implementation block, all associated functions & methods go in here
|
||||
impl Point {
|
||||
// This is an "associated function" because this function is associated with
|
||||
// a particular type, that is, Point.
|
||||
//
|
||||
// Associated functions don't need to be called with an instance.
|
||||
// These functions are generally used like constructors.
|
||||
fn origin() -> Point {
|
||||
Point { x: 0.0, y: 0.0 }
|
||||
}
|
||||
|
||||
// Another associated function, taking two arguments:
|
||||
fn new(x: f64, y: f64) -> Point {
|
||||
Point { x: x, y: y }
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Modules and imports
|
||||
|
||||
```rust
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use deeply::nested::function as other_function;
|
||||
use std::fs::File;
|
||||
|
||||
fn function() {
|
||||
println!("called function()");
|
||||
}
|
||||
|
||||
mod deeply {
|
||||
pub mod nested {
|
||||
pub fn function() {
|
||||
println!("called deeply::nested::function()");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Val {
|
||||
val: f64,
|
||||
}
|
||||
|
||||
struct GenVal<T> {
|
||||
gen_val: T,
|
||||
}
|
||||
|
||||
// impl of Val
|
||||
impl Val {
|
||||
fn value(&self) -> &f64 {
|
||||
&self.val
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> GenVal<T> {
|
||||
fn value(&self) -> &T {
|
||||
&self.gen_val
|
||||
}
|
||||
}
|
||||
|
||||
let a = Box::new(5i32);
|
||||
|
||||
macro_rules! say_hello {
|
||||
() => {
|
||||
// The macro will expand into the contents of this block.
|
||||
println!("Hello!")
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! calculate {
|
||||
(eval $e:expr) => {
|
||||
{
|
||||
let val: usize = $e; // Force types to be unsigned integers
|
||||
println!("{} = {}", stringify!{$e}, val);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn give_adult(drink: Option<&str>) {
|
||||
// Specify a course of action for each case.
|
||||
match drink {
|
||||
Some("lemonade") => println!("Yuck! Too sugary."),
|
||||
Some(inner) => println!("{}? How nice.", inner),
|
||||
None => println!("No drink? Oh well."),
|
||||
}
|
||||
}
|
||||
|
||||
impl Person {
|
||||
|
||||
// Gets the area code of the phone number of the person's job, if it exists.
|
||||
fn work_phone_area_code(&self) -> Option<u8> {
|
||||
// It would take a lot more code - try writing it yourself and see which
|
||||
// is easier.
|
||||
self.job?.phone_number?.area_code
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "unix")]
|
||||
#[link(name = "m")]
|
||||
extern {
|
||||
// this is a foreign function
|
||||
// that computes the square root of a single precision complex number
|
||||
fn csqrtf(z: Complex) -> Complex;
|
||||
|
||||
fn ccosf(z: Complex) -> Complex;
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let raw_p: *const u32 = &10;
|
||||
|
||||
unsafe {
|
||||
assert!(*raw_p == 10);
|
||||
}
|
||||
}
|
||||
```
|
|
@ -11,7 +11,6 @@ code {
|
|||
/* Styling for fenced code blocks */
|
||||
pre > code {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
padding: 1rem;
|
||||
border-radius: 0.55rem / 0.5rem;
|
||||
word-wrap: normal;
|
||||
|
|
|
@ -53,23 +53,29 @@ a:visited {
|
|||
.site-tagline {
|
||||
color: var(--dim-0);
|
||||
}
|
||||
.post-body, .post-header, .post-blurbs {
|
||||
.post-body, .post-header, .post-blurbs, .tags, .tags .tag-blurb-post {
|
||||
background-color: var(--bg-0);
|
||||
}
|
||||
.post-blurb {
|
||||
.post-blurb, .tags .tag-blurb {
|
||||
background-color: var(--bg-1);
|
||||
}
|
||||
.post-title, .post-blurbs > h1 {
|
||||
:not(.tags) .tag-blurb {
|
||||
background-color: var(--bg-0);
|
||||
}
|
||||
:not(.tags) .tag-blurb-post {
|
||||
background-color: var(--bg-1);
|
||||
}
|
||||
.post-title, .post-blurbs h1 {
|
||||
color: var(--green);
|
||||
}
|
||||
.post-body > h2, .post-body > h3, .post-body > h4 {
|
||||
.post-body h2, .post-body h3, .post-body h4 {
|
||||
color: var(--fg-1);
|
||||
}
|
||||
blockquote {
|
||||
background-color: var(--bg-1);
|
||||
}
|
||||
|
||||
/* Colorization for code blocks */
|
||||
/* Colorization for idris code blocks */
|
||||
code {
|
||||
color: var(--code-fg-0);
|
||||
background-color: var(--code-bg-0);
|
||||
|
@ -96,3 +102,35 @@ code {
|
|||
.hl-data {
|
||||
color: var(--code-red);
|
||||
}
|
||||
|
||||
/* Colorization for pygments code blocks */
|
||||
.hl-kd, .hl-k, .hl-kc, .hl-bp {
|
||||
color: var(--code-green);
|
||||
}
|
||||
.hl-n, .hl-nn {
|
||||
color: var(--code-violet);
|
||||
}
|
||||
.hl-s, .hl-se {
|
||||
color: var(--code-cyan);
|
||||
}
|
||||
.hl-nf, .hl-fm {
|
||||
color: var(--code-blue);
|
||||
}
|
||||
.hl-c1, .hl-cm {
|
||||
color: var(--code-dim-0);
|
||||
}
|
||||
.hl-mf, .hl-mi {
|
||||
color: var(--code-magenta);
|
||||
}
|
||||
.hl-kt, .hl-nb, .hl-nc {
|
||||
color: var(--code-orange);
|
||||
}
|
||||
.hl-cp {
|
||||
color: var(--code-red);
|
||||
}
|
||||
.hl-se {
|
||||
font-style: italic;
|
||||
}
|
||||
.hl-fm, .hl-k, .hl-o, .hl-kp {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
font-family: "Open Sans", sans-serif, serif;
|
||||
/* Variables */
|
||||
--content-width: 60rem;
|
||||
--blurb-width: 45%;
|
||||
--header-width: 35rem;
|
||||
--box-padding-vert: 1rem;
|
||||
--box-padding-horz: 1rem;
|
||||
|
@ -18,6 +19,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* slightly larger than blurb-width to account for padding/margins */
|
||||
@media screen and (max-width: 40rem) {
|
||||
:root {
|
||||
--blurb-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Main Body and Post Flexboxs */
|
||||
body, .post {
|
||||
display: flex;
|
||||
|
@ -25,6 +33,9 @@ body, .post {
|
|||
align-items: center;
|
||||
gap: var(--box-gap);
|
||||
}
|
||||
.post {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Style the site header */
|
||||
.site-header {
|
||||
|
@ -75,11 +86,11 @@ body, .post {
|
|||
border-radius: var(--box-radius);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.post-body > p {
|
||||
.post-body p {
|
||||
margin: auto var(--box-margin-horz);
|
||||
align-self: stretch;
|
||||
}
|
||||
.post-title > h1 {
|
||||
.post-title h1 {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
@ -94,7 +105,7 @@ body, .post {
|
|||
.post-read-time {
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
.post-body > h2, .post-body > h3, .post-body > h4 {
|
||||
.post-body h2, .post-body h3, .post-body h4 {
|
||||
text-align: center;
|
||||
}
|
||||
.post-blurbs {
|
||||
|
@ -124,3 +135,56 @@ blockquote {
|
|||
border-radius: var(--box-radius);
|
||||
padding: var(--box-padding-vert) var(--box-padding-horz);
|
||||
}
|
||||
|
||||
/* Style the tags blurbs and page */
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--box-gap);
|
||||
max-width: var(--content-width);
|
||||
/* min-width: var(--blurb-width); */
|
||||
padding: var(--box-padding-vert) var(--box-padding-horz);
|
||||
border-radius: var(--box-radius);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tag-blurb {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--box-gap);
|
||||
border-radius: var(--box-radius);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tag-blurb-links {
|
||||
display: block;
|
||||
border-radius: var(--box-radius);
|
||||
border-radius: var(--box-radius);
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: var(--box-gap);
|
||||
align-items: stretch;
|
||||
box-sizing: border-box;
|
||||
gap: var(--box-gap);
|
||||
padding: var(--box-padding-vert) var(--box-padding-horz);
|
||||
}
|
||||
.tag-blurb-post {
|
||||
font-size: 0.8rem;
|
||||
width: var(--blurb-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--box-gap);
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
border-radius: var(--box-radius);
|
||||
padding: var(--box-padding-vert) var(--box-padding-horz);
|
||||
}
|
||||
.tag-blurb-title {
|
||||
margin-top: var(--box-margin-vert);
|
||||
margin-bottom: 0;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue