Compare commits
10 commits
bc79817184
...
53c0c6a9d6
Author | SHA1 | Date | |
---|---|---|---|
53c0c6a9d6 | |||
7d5cbfba3c | |||
73aefa28eb | |||
d1eb6f1762 | |||
c59c266ee1 | |||
d861c7ef87 | |||
d4f11a5585 | |||
9ccc512533 | |||
e7fdf59618 | |||
e833e18748 |
9 changed files with 443 additions and 23 deletions
22
blog
22
blog
|
@ -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
64
lib/Atom.rakumod
Normal 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
|
||||
}
|
|
@ -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';
|
||||
' ';
|
||||
$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;
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue