Compare commits

...

10 commits

9 changed files with 443 additions and 23 deletions

22
blog
View file

@ -44,8 +44,12 @@ multi MAIN(
my $title = get;
print "Tagline: ";
my $tagline = get;
print "Base URL: ";
my $base-url = get;
my $meta = BlogMeta.new: title => $title, tagline => $tagline;
my $meta =
BlogMeta.new:
title => $title, tagline => $tagline, base-url => $base-url;
my $db = DB::PostDB.init: $meta;
@ -118,6 +122,22 @@ multi MAIN(
say 'Post has slugs: ', $db.posts{$id}.all-slugs;
}
#| Update the last editied time on a post
multi MAIN(
"touch",
#| The post id to touch
Int:D $id,
#| The path of the database file
IO::Path(Str) :$db-dir = $default-db-dir,
#| The date/time the post should be recorded as laste edited at
DateTime(Str) :$edited-at = DateTime.now,
) {
my $db = read-db $db-dir;
my $post = $db.posts{$id.Int};
$post.edited-at.push: $edited-at;
$db.write: $db-dir;
}
#| Render the blog to html
multi MAIN(
"render",

64
lib/Atom.rakumod Normal file
View file

@ -0,0 +1,64 @@
use v6.e.PREVIEW;
use DB::Post;
use DB::BlogMeta;
use XML;
unit module Atom;
# get the link for a post
sub post-link(BlogMeta:D $meta, Int:D $id, Post:D $post --> Str:D) {
my @slugs = $post.all-slugs;
my $base = $meta.get-base-url;
if @slugs.elems {
"$base/posts/by-slug/{@slugs[*-1]}.html"
} else {
"$base/posts/by-id/$id.html"
}
}
#| Convert a single post to an atom item
sub post-to-item(BlogMeta:D $meta, Int:D $id, Post:D $post --> XML::Element) {
my $link = post-link $meta, $id, $post;
my $desc = $post.description;
my $xml = XML::Element.new(:name<entry>);
$xml.append:
XML::Element.new(:name<id>, :nodes([$link]));
$xml.append:
XML::Element.new(:name<title>, :nodes([$post.title]));
$xml.append:
XML::Element.new(:name<updated>, :nodes([$post.updated]));
$xml.append:
XML::Element.new(:name<published>, :nodes([$post.posted-at]));
my $author = XML::Element.new(:name<author>);
$author.append:
XML::Element.new(:name<email>, :nodes(["thatonelutenist@stranger.systems"]));
$author.append:
XML::Element.new(:name<name>, :nodes(["Nathan McCarty"]));
$xml.append: $author;
$xml.append:
XML::Element.new(:name<link>, :attribs({:href($link), :rel<alternate>}));
$xml.append:
XML::Element.new(:name<summary>, :nodes([$desc])) if $desc;
$xml
}
#| Produce an atom feed from the database
sub posts-to-atom($db --> XML::Element) is export {
my $updated = $db.posts.values.map(*.updated).max;
my $xml =
XML::Element.new(
:name<feed>,
:attribs({:xmlns('http://www.w3.org/2005/Atom')}));
$xml.append: XML::Element.new(:name<id>, :nodes([$db.meta.get-base-url]));
$xml.append: XML::Element.new(:name<title>, :nodes([$db.meta.title]));
$xml.append: XML::Element.new(:name<updated>, :nodes([$updated]));
for $db.sorted-posts -> $pair {
unless $pair.value.hidden {
$xml.append: post-to-item $db.meta, $pair.key, $pair.value;
}
}
$xml
}

View file

@ -6,12 +6,16 @@ use DB::Post;
unit class Config;
method generate-head(Str:D $title, BlogMeta:D $meta, $description?) {
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>;
title "{$meta.title} $title";
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)
@ -22,11 +26,15 @@ method generate-head(Str:D $title, BlogMeta:D $meta, $description?) {
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;
# Load fonts, Iosevka Alie for code, and Open Sans for content
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/IosevkaAlie/IosevkaAile.css>;
: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>;
# Inline our style sheets
style %?RESOURCES<main.css>.slurp;
style %?RESOURCES<code.css>.slurp;
@ -43,8 +51,115 @@ method site-header(BlogMeta:D $meta) {
$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';
'&nbsp;';
$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';
'&nbsp;';
$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';
'&nbsp;';
mins-to-string $slow;
'&nbsp;';
'/';
'&nbsp;';
mins-to-string $average;
'&nbsp;';
'/';
'&nbsp;';
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;
]
}
@ -55,8 +170,11 @@ method generate-post(Post:D $post, BlogMeta:D $meta) {
my $body =
body [
self.site-header: $meta;
div :class<post-body>, [
$content
article :class<post>, [
self.post-header: $post;
div :class<post-body>, [
$content;
]
]
];
# TODO: Setup footer
@ -69,3 +187,85 @@ method generate-post(Post:D $post, BlogMeta:D $meta) {
"<!doctype html>$html"
}
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"
}
div :class<post-blurb>, [
div :class<post-blurb-title>, [
a :href($link), span [
h2 $post.title;
];
];
self.post-info: $post;
if $desc ~~ Str:D {
div :class<post-blurb-description>, [
p $post.description;
];
} else {
[]
}
]
}
method generate-index($db) {
my @most-recent =
$db.sorted-posts
.head(10)
.grep(!*.value.hidden)
.map(-> $pair {
self.generate-blurb: $pair.key, $db
});
my $head = self.generate-head(Nil, $db.meta);
my $body = body [
self.site-header: $db.meta;
div :class<post-blurbs>, [
h1 "Recent Posts"
], @most-recent;
];
my $html =
html :lang<en>, [
$head,
$body
];
"<!doctype html>$html"
}
method generate-archive($db) {
my @most-recent =
$db.sorted-posts
.grep(!*.value.hidden)
.map(-> $pair {
self.generate-blurb: $pair.key, $db
});
my $head = self.generate-head(Nil, $db.meta);
my $body = body [
self.site-header: $db.meta;
div :class<post-blurbs>, [
h1 "All Posts"
], @most-recent;
];
my $html =
html :lang<en>, [
$head,
$body
];
"<!doctype html>$html"
}
sub icon($icon) {
i(:class("bx bx-$icon"))
}

View file

@ -5,12 +5,14 @@ unit module DB;
use Pandoc;
use JSON::Class:auth<zef:vrurg>;
use XML;
use DB::Post;
use DB::BlogMeta;
use DB::MarkdownPost;
use DB::IdrisPost;
use DB::PlaceholderPost;
use Atom;
use Config;
subset PostTypes where MarkdownPost:D | IdrisPost:D | PlaceholderPost:D;
@ -83,15 +85,28 @@ class PostDB {
$id-path.spurt: $html;
for $post.all-slugs -> $slug {
# remove the symlink if it already exists
my $slug-path = $by-slug.add: $slug;
my $slug-path = $by-slug.add: "$slug.html";
$slug-path.unlink if $slug-path.l;
$id-path.symlink: $slug-path;
}
}
# Render the archive
# Render the rss/atom feed
# Render the index
die "Not Implemented"
$out-dir.add('index.html').spurt: $config.generate-index(self);
# Render the archive
$out-dir.add('archive.html').spurt: $config.generate-archive(self);
# Symlink the about article
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;
# Render the rss/atom feed
my $atom-path = $out-dir.add('atom.xml');
my $atom = posts-to-atom self;
$atom-path.spurt: ~$atom;
}
#| Get a list of posts sorted by date
method sorted-posts() {
%!posts.sort(*.value.posted-at).reverse
}
}

View file

@ -13,3 +13,19 @@ has Str:D $.tagline is required is rw;
#| The id of the placeholder post
has Int:D $.placeholder-id is rw = 0;
#| The id of the about post
has Int:D $.about-id is rw = 0;
#| The base url of this post
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<BLOG_TEST> {
"http://localhost:8080"
} else {
$!base-url
}
}

View file

@ -24,6 +24,7 @@ DateTime:D $.posted-at
#| An optional list of edit times for the post
has
DateTime:D @.edited-at
is rw
is json(
:to-json()
value => { $^value.Str }
@ -42,6 +43,15 @@ has Bool:D $.hidden is json is rw = False;
#| document produced it
method title(--> Str:D) {...}
#| The time the post was last updated at
method updated(--> DateTime:D) {
if @!edited-at {
@!edited-at.max
} else {
$!posted-at
}
}
#| Get the list of slugs for this post, including ones auto generated from
#| the title, as well as any additional slugs
method all-slugs(--> Array[Str:D]) {
@ -58,3 +68,9 @@ method render-html(--> Str:D) {...}
method description(--> Str) {
Nil
}
#| Estimated readtimes at 140/180/220 wpm
method readtimes() {
my $word-count = $!source.slurp.words.elems;
($word-count / 140, $word-count / 180, $word-count / 220).map(*.ceiling)
}

View file

@ -92,5 +92,8 @@ sub markdown-first-paragraph(IO::Path:D $file --> Str:D) is export {
#| Use pandoc to render a markdown document to html
sub markdown-to-html(IO::Path:D $file --> Str:D) is export {
pandoc <-f gfm>, $file
# Remove the header, we'll regenerate it later
my $output = pandoc <-f gfm>, $file;
$output ~~ s:g/'<h1' .* '</h1>'//;
$output
}

View file

@ -1,12 +1,13 @@
code {
font-family: "Iosevka Aile Web", monospace;
font-family: "Iosevka Web", monospace;
background-color: light-dark(#fbf3db, #103c48);
color: light-dark(#53676d, #adbcbc);
min-width: 80ch;
width: 80%;
display: block;
padding: 1rem;
border-radius: 0.55rem / 0.5rem;
white-space: pre-wrap;
overflow-x: auto;
}
pre {

View file

@ -5,7 +5,7 @@
font-family: "Open Sans", sans-serif, serif;
}
body {
body, .post {
display: flex;
align-items: center;
justify-content: center;
@ -13,8 +13,18 @@ body {
gap: 1rem;
}
.header-links {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
font-size: 1.1rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.site-header {
width: 60%;
width: 50%;
display: block;
padding: 1rem;
border-radius: 1rem;
@ -26,26 +36,93 @@ body {
.site-logo {
color: light-dark(#d6000c, #ed4a46);
font-size: 2.5rem;
font-weight: bold;
}
.site-tagline {
color: light-dark(#909995, #777777);
font-size: 0.9rem;
font-style: italic;
}
.post-body {
.post-body, .post-header, .post-blurbs {
width: 66%;
display: block;
padding-left: 2rem;
padding-right: 2rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
border-radius: 1rem;
background-color: light-dark(#ffffff, #181818);
/* text-align: justify; */
}
.post-body > h1 {
.post-blurbs {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 1rem;
}
.post-blurb {
width: 100%;
display: block;
border-radius: 1rem;
padding: 0.5rem;
background-color: light-dark(#ebebeb, #252525);
/* background-color: light-dark(#cdcdcd, #3b3b3b); */
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
/* gap: 0.25rem; */
}
.post-header {
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.post-title {
text-align: center;
color: light-dark(#dd0f9d, #eb6eb7);
}
.post-blurbs > h1 {
color: light-dark(#1d9700, #70b433);
}
a:link {
color: light-dark(#009c8f, #41c7b9);
}
a:visited {
color: light-dark(#dd0f9d, #eb6eb7);
}
.post-title > h1, .post-blurb-title > h2 {
margin-top: 0px;
margin-bottom: 0px;
}
.post-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
color: light-dark(#909995, #777777);
font-size: 0.9rem;
flex-wrap: wrap;
}
.post-read-time {
text-decoration: underline dotted;
}
.post-body > h2 {
text-align: center;
color: light-dark(#282828, #dedede);
@ -60,6 +137,14 @@ body {
text-align: center;
color: light-dark(#282828, #dedede);
}
.post-body > p {
text-indent: 2ch;
/* .post-body > p { */
/* text-indent: 2ch; */
/* } */
a > span {
text-decoration: underline;
}
a {
text-decoration: none;
}