Compare commits

..

14 Commits

15 changed files with 290 additions and 64 deletions
+1
View File
@@ -5,6 +5,7 @@
"start": "astro build --remote && node ./dist/server/entry.mjs", "start": "astro build --remote && node ./dist/server/entry.mjs",
"build": "astro build --remote", "build": "astro build --remote",
"preview": "astro preview", "preview": "astro preview",
"sync": "astro sync",
"astro": "astro" "astro": "astro"
}, },
"engines": { "engines": {
+1
View File
@@ -24,6 +24,7 @@
.h-feed .h-entry .p-name { .h-feed .h-entry .p-name {
font-size: var(--font-size-md); font-size: var(--font-size-md);
font-weight: 400;
} }
.h-feed .full-feed-link { .h-feed .full-feed-link {
+5
View File
@@ -46,6 +46,7 @@ function pubDate(post: CollectionEntry<'blog'>): Date {
const HeadingElem = `h${headingLevel} class="p-name"`; const HeadingElem = `h${headingLevel} class="p-name"`;
const SubHeadingElem = `h${headingLevel + 1}`; const SubHeadingElem = `h${headingLevel + 1}`;
const SubSubHeadingElem = `h${headingLevel + 2}`;
const AuthorElem = `p${hideAuthor ? " hidden" : ""}`; const AuthorElem = `p${hideAuthor ? " hidden" : ""}`;
const canonicalBlogUrl = new URL('blog', Astro.site) const canonicalBlogUrl = new URL('blog', Astro.site)
@@ -72,7 +73,9 @@ const canonicalBlogUrl = new URL('blog', Astro.site)
? <ul> ? <ul>
{ posts.sort(sortByPubDateDescending).map(post => ( { posts.sort(sortByPubDateDescending).map(post => (
<li class="h-entry"> <li class="h-entry">
<SubSubHeadingElem>
<a class="u-url p-name" href={`/blog/${post.id}`}>{post.data.title}</a> <a class="u-url p-name" href={`/blog/${post.id}`}>{post.data.title}</a>
</SubSubHeadingElem>
<p class="p-summary" set:html={post.data.description} /> <p class="p-summary" set:html={post.data.description} />
<FormattedDate className="dt-published" date={pubDate(post)} /> <FormattedDate className="dt-published" date={pubDate(post)} />
</li> </li>
@@ -83,7 +86,9 @@ const canonicalBlogUrl = new URL('blog', Astro.site)
<ul> <ul>
{ posts.filter(matchesYear(year)).sort(sortByPubDateDescending).map(post => ( { posts.filter(matchesYear(year)).sort(sortByPubDateDescending).map(post => (
<li class="h-entry"> <li class="h-entry">
<SubSubHeadingElem>
<a class="u-url p-name" href={`/blog/${post.id}`}>{post.data.title}</a> <a class="u-url p-name" href={`/blog/${post.id}`}>{post.data.title}</a>
</SubSubHeadingElem>
<p class="p-summary" set:html={post.data.description} /> <p class="p-summary" set:html={post.data.description} />
<FormattedDate className="dt-published" date={pubDate(post)} /> <FormattedDate className="dt-published" date={pubDate(post)} />
</li> </li>
+9 -7
View File
@@ -2,20 +2,22 @@
interface Props { interface Props {
className?: string; className?: string;
date: Date | string; date: Date | string;
hidden?: boolean;
} }
let { className, date } = Astro.props; let { className, date, hidden } = Astro.props;
if (typeof(date) === 'string') { if (typeof(date) === 'string') {
date = new Date(date); date = new Date(date);
} }
--- const dateStr =
<time datetime={date.toISOString()} class={className ?? ''}>
{
date.toLocaleDateString('en-GB', { date.toLocaleDateString('en-GB', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
}) });
---
{ hidden
? <time datetime={date.toISOString()} class={className ?? ''} hidden>{dateStr}</time>
: <time datetime={date.toISOString()} class={className ?? ''}>{dateStr}</time>
} }
</time>
+100
View File
@@ -0,0 +1,100 @@
---
import type { CollectionEntry } from 'astro:content';
import { getCollection } from 'astro:content';
import FormattedDate from './FormattedDate.astro';
import { renderGemtextToHtml } from '../renderGemtextToHtml';
export interface Props {
headingLevel?: 1 | 2 | 3 | 4 | 5 | 6,
hideAuthor?: boolean,
hideSubheadings?: boolean,
maxEntries?: number,
};
const { headingLevel = 2, hideSubheadings = false, hideAuthor = false, maxEntries } = Astro.props;
const allPosts = await getCollection('microlog');
const posts = maxEntries === undefined
? allPosts
: allPosts.sort(sortByIdDescending).slice(0, maxEntries);
const distinctYears: number[] = posts
.map(post => pubDate(post).getFullYear())
.reduce<number[]>((acc, curr) => acc.includes(curr) ? acc : [...acc, curr], [])
.sort((a, b) => b - a);
function matchesYear(year: number) {
return (post: CollectionEntry<'microlog'>) => pubDate(post).getFullYear() === year;
}
function sortByIdDescending(post1: CollectionEntry<'microlog'>, post2: CollectionEntry<'microlog'>) {
return post2.id.localeCompare(post1.id);
}
function pubDate(post: CollectionEntry<'microlog'>): Date {
console.warn(post.id);
const date = new Date(post.id.replace(/\.[0-9]+$/g, ''));
if (isNaN(date.valueOf())) {
throw new Error(`Post ${post.id} does not have a valid publication date.`);
}
return date;
}
const HeadingElem = `h${headingLevel} class="p-name"`;
const SubHeadingElem = `h${headingLevel + 1}`;
const SubSubHeadingElem = `h${headingLevel + 2}`;
const AuthorElem = `p${hideAuthor ? " hidden" : ""}`;
const canonicalMicrologUrl = new URL('microlog', Astro.site)
---
<section class="h-feed">
<HeadingElem>
My microlog
</HeadingElem>
<aside>
<AuthorElem>
This microlog is written by <a class="p-author h-card" href="/">Joe Carstairs</a>
</AuthorElem>
<p hidden>
<a class="u-url" href={canonicalMicrologUrl}>Permalink</a>
</p>
</aside>
<slot />
{ hideSubheadings
? <ul>
{ posts.sort(sortByIdDescending).map(post => (
<li class="h-entry">
<SubSubHeadingElem>
<a class="u-url p-name" href={`/microlog/${post.id}`}>{post.id}</a>
</SubSubHeadingElem>
<section class="e-content" set:html={post.body} />
<FormattedDate hidden={true} className="dt-published" date={pubDate(post)} />
</li>
)) }
</ul>
: distinctYears.map(year => (
<SubHeadingElem>{year}</SubHeadingElem>
<ul>
{ posts.filter(matchesYear(year)).sort(sortByIdDescending).map(post => (
<li class="h-entry">
<SubSubHeadingElem>
<a class="u-url p-name" href={`/microlog/${post.id}`}>{post.id}</a>
</SubSubHeadingElem>
<section class="e-content" set:html={renderGemtextToHtml(post.body ?? '')} />
<FormattedDate hidden={true} className="dt-published" date={pubDate(post)} />
</li>
)) }
</ul>
)) }
{ (maxEntries !== undefined && maxEntries < allPosts.length)
? <p class="full-feed-link"><a href="/microlog">All microlog posts</a></p>
: <></>
}
</section>
+3
View File
@@ -9,6 +9,9 @@
<li> <li>
<a href="/blog">Blog</a> <a href="/blog">Blog</a>
</li> </li>
<li>
<a href="/microlog">Microlog</a>
</li>
<li> <li>
<a href="/links">Links</a> <a href="/links">Links</a>
</li> </li>
+10 -1
View File
@@ -2,6 +2,7 @@ import { defineCollection } from "astro:content";
import { z } from "astro/zod"; import { z } from "astro/zod";
import { extendedGlob } from "./loaders/extended-glob"; import { extendedGlob } from "./loaders/extended-glob";
import { glob } from "astro/loaders";
const blog = defineCollection({ const blog = defineCollection({
loader: extendedGlob({ loader: extendedGlob({
@@ -49,4 +50,12 @@ const blog = defineCollection({
}), }),
}); });
export const collections = { blog }; const microlog = defineCollection({
loader: glob({
pattern: "**/*.gmi",
base: "./src/content/microlog",
generateId: ({ entry }) => entry.replace(/\.gmi$/, ""),
}),
});
export const collections = { blog, microlog };
+1
View File
@@ -0,0 +1 @@
../../../common/microlog/
+1 -2
View File
@@ -1,5 +1,4 @@
--- ---
import type { CollectionEntry } from 'astro:content';
import BaseHead from '../components/BaseHead.astro'; import BaseHead from '../components/BaseHead.astro';
import FormattedDate from '../components/FormattedDate.astro'; import FormattedDate from '../components/FormattedDate.astro';
import Navbar from '../components/Navbar.astro'; import Navbar from '../components/Navbar.astro';
@@ -19,7 +18,7 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
<html lang="en"> <html lang="en">
<head> <head>
<BaseHead title={`${title} | joeacs blog`} description={description} /> <BaseHead title={`${title} | joeacs blog`} description={description} />
<link rel="stylesheet" href="/css/blog.css"> <link rel="stylesheet" href="/css/post.css">
</head> </head>
<body> <body>
+43
View File
@@ -0,0 +1,43 @@
---
import BaseHead from '../components/BaseHead.astro';
import FormattedDate from '../components/FormattedDate.astro';
import Navbar from '../components/Navbar.astro';
type Props = {
pubDate: Date;
title: string;
}
const { pubDate, title } = Astro.props;
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
---
<html lang="en">
<head>
<BaseHead title={`${title} | joeacs microlog`} description={`${title} | joeac's microlog`} />
<link rel="stylesheet" href="/css/post.css">
</head>
<body>
<Navbar />
<article class="h-entry">
<aside>
<span>
This is a <a href="/microlog">microlog</a> post by
<a class="p-author h-card" href="/">Joe Carstairs</a>.
</span>
<span>Published: <FormattedDate date={pubDate} className="dt-published"/>.</span>
<span hidden><a class="u-url uid" href={canonicalUrl}>Permalink</a></span>
</aside>
<header>
<h1 class="h-name">{title}</h1>
</header>
<section class="e-content">
<slot />
</section>
</article>
</body>
</html>
+2 -50
View File
@@ -1,7 +1,7 @@
--- ---
import { type CollectionEntry, getCollection, render } from 'astro:content'; import { type CollectionEntry, getCollection, render } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro'; import BlogPost from '../../layouts/BlogPost.astro';
import * as Gemtext from 'gemtext'; import { renderGemtextToHtml } from '../../renderGemtextToHtml';
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = await getCollection('blog'); const posts = await getCollection('blog');
@@ -12,57 +12,9 @@ export async function getStaticPaths() {
} }
type Props = CollectionEntry<'blog'>; type Props = CollectionEntry<'blog'>;
function htmlEscape(str: string) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const GemtextHTMLRenderer: Gemtext.Renderer<string> = {
preamble: function (): string {
return '';
},
postamble: function (): string {
return '';
},
text: function (content: string): string {
content = content.trim();
if (content === '') {
return '';
}
return `<p>${htmlEscape(content)}</p>`
},
link: function (url: string, alt: string): string {
const imgExtensions: (string | undefined)[] = ['webp', 'png', 'svg', 'gif', 'jpg', 'jpeg', 'apng']
if (imgExtensions.includes(url.split('.').at(-1)?.toLowerCase())) {
return `<img src="${url}" alt="${alt}" />`;
}
return `<p>=> <a href="${url}">${alt}</a></p>`;
},
preformatted: function (content: string[], alt: string): string {
return `<pre alt="${alt}">${htmlEscape(content.join('\n'))}</pre>`
},
heading: function (level: number, text: string): string {
return `<h${level}>${htmlEscape(text)}</h${level}>`;
},
unorderedList: function (content: string[]): string {
return `<ul>${content.map(li=>`<li>${htmlEscape(li)}</li>`).join('')}</ul>`;
},
quote: function (content: string): string {
return `<blockquote>${htmlEscape(content)}</blockquote>`;
}
};
function postProcessGemtextToHtml(html: string): string {
html = mergeAdjacentBlockquotes(html);
return html;
}
function mergeAdjacentBlockquotes(html: string): string {
return html.replaceAll(/\<\/blockquote\>\<blockquote\>/gm, '<br>');
}
const post = Astro.props; const post = Astro.props;
const Content = post.filePath?.endsWith('.gmi') const Content = post.filePath?.endsWith('.gmi')
? postProcessGemtextToHtml(Gemtext.parse((post.body ?? '').replace(`# ${post.data.title}`, '')).generate(GemtextHTMLRenderer)) ? renderGemtextToHtml(post.body ?? '', post.data.title)
: (await render(post)).Content; : (await render(post)).Content;
--- ---
@@ -0,0 +1,26 @@
---
import { type CollectionEntry, getCollection, render } from 'astro:content';
import MicrologPost from '../../layouts/MicrologPost.astro';
import { renderGemtextToHtml } from '../../renderGemtextToHtml';
export async function getStaticPaths() {
const posts = await getCollection('microlog');
return posts.map((post) => ({
params: { slug: post.id },
props: post,
}));
}
type Props = CollectionEntry<'microlog'>;
const { id: slug, body } = Astro.props;
const pubDateStr = slug.replaceAll(/\.[0-9]+/g, '');
const pubDate = new Date(pubDateStr);
if (isNaN(pubDate.valueOf())) {
throw new Error(`Could not construct a valid publication date for microlog post: ${slug}`);
}
const Content = renderGemtextToHtml(body ?? '', pubDateStr);
---
<MicrologPost pubDate={pubDate} title={pubDateStr}>
<Fragment set:html={Content}></Fragment>
</MicrologPost>
+13
View File
@@ -0,0 +1,13 @@
---
import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts';
import MicrologFeed from '../../components/MicrologFeed.astro';
import Page from '../../layouts/Page.astro';
---
<Page title={SITE_TITLE} description={SITE_DESCRIPTION}>
<MicrologFeed headingLevel={1}>
<!-- <section>
<p><a href="/blog/subscribe">How to subscribe to this blog</a></p>
</section> -->
</MicrologFeed>
</Page>
+71
View File
@@ -0,0 +1,71 @@
import * as Gemtext from "gemtext";
export function renderGemtextToHtml(gemtext: string, title?: string): string {
gemtext = title ? gemtext.replace(`# ${title}`, "") : gemtext;
return postProcessGemtextToHtml(
Gemtext.parse(gemtext).generate(GemtextHTMLRenderer),
);
}
const GemtextHTMLRenderer: Gemtext.Renderer<string> = {
preamble: function (): string {
return "";
},
postamble: function (): string {
return "";
},
text: function (content: string): string {
content = content.trim();
if (content === "") {
return "";
}
return `<p>${htmlEscape(content)}</p>`;
},
link: function (url: string, alt: string): string {
const imgExtensions: (string | undefined)[] = [
"webp",
"png",
"svg",
"gif",
"jpg",
"jpeg",
"apng",
];
if (imgExtensions.includes(url.split(".").at(-1)?.toLowerCase())) {
return `<img src="${url}" alt="${alt}" />`;
}
return `<p>=> <a href="${url}">${alt}</a></p>`;
},
preformatted: function (content: string[], alt: string): string {
return `<pre alt="${alt}">${htmlEscape(content.join("\n"))}</pre>`;
},
heading: function (level: number, text: string): string {
return `<h${level}>${htmlEscape(text)}</h${level}>`;
},
unorderedList: function (content: string[]): string {
return `<ul>${content.map((li) => `<li>${htmlEscape(li)}</li>`).join("")}</ul>`;
},
quote: function (content: string): string {
return `<blockquote>${htmlEscape(content)}</blockquote>`;
},
};
function htmlEscape(str: string) {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function postProcessGemtextToHtml(html: string): string {
html = mergeAdjacentBlockquotes(html);
return html;
}
function mergeAdjacentBlockquotes(html: string): string {
return html.replaceAll(/\<\/blockquote\>\<blockquote\>/gm, "<br>");
}