You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

200 lines
6.3 KiB

import * as fs from 'fs/promises'
import * as matter from 'gray-matter'
import * as marked from 'marked'
import * as RSSFeed from 'feed'
import * as xml2js from 'xml2js'
import {Singleton, appPath, Collection, Inject, Maybe, collect, Logging, hasOwnProperty, Routing} from '@extollo/lib'
export interface BlogPostFrontMatter {
title: string
slug: string
date: Date
tags: string[]
}
export const isBlogPostFrontMatter = (what: unknown): what is BlogPostFrontMatter => {
return typeof what === 'object' && what !== null
&& hasOwnProperty(what, 'title') && typeof what.title === 'string'
&& hasOwnProperty(what, 'slug') && typeof what.slug === 'string'
&& hasOwnProperty(what, 'date') && what.date instanceof Date
&& hasOwnProperty(what, 'tags') && Array.isArray(what.tags)
&& what.tags.every(tag => typeof tag === 'string')
}
export type BlogPost = BlogPostFrontMatter & {
file: string
markdown: string
}
export interface FeedSub {
category: string
name: string
url: string
}
@Singleton()
export class Blog {
@Inject()
protected readonly logging!: Logging
@Inject()
protected readonly routing!: Routing
protected posts: Maybe<Collection<BlogPost>>
protected postRenderCache: Record<string, string> = {}
protected cachedFeed: Maybe<RSSFeed.Feed>
async getAllPosts(): Promise<Collection<BlogPost>> {
if ( !this.posts ) {
this.posts = collect()
const path = appPath('resources', 'blog-posts')
const contents = await fs.readdir(path.toLocal)
for ( const file of contents ) {
if ( !file.endsWith('.md') ) continue
const filePath = path.concat(file)
const fileContents = await filePath.read()
const parsed = matter(fileContents)
const front = parsed.data
if ( !isBlogPostFrontMatter(front) ) {
this.logging.warn(`The following blog post had invalid front-matter: ${filePath}`)
continue
}
this.posts.push({
...front,
file,
markdown: parsed.content,
})
}
this.posts = this.posts.sortByDesc('date')
}
return this.posts
}
getPost(slug: string): Promise<Maybe<BlogPost>> {
return this.getAllPosts()
.then(p => p.firstWhere('slug', '=', slug))
}
async renderPost(slug: string): Promise<Maybe<string>> {
const cached = this.postRenderCache[slug]
if ( cached ) {
return cached
}
const post = await this.getPost(slug)
if ( !post ) {
return undefined
}
const render = marked.marked(post.markdown)
this.postRenderCache[slug] = render
return render
}
async getFeed(): Promise<RSSFeed.Feed> {
if ( this.cachedFeed ) {
return this.cachedFeed
}
const posts = await this.getAllPosts()
const feed = new RSSFeed.Feed({
title: 'Garrett\'s Blog',
description: 'Write-ups and musings by Garrett Mills, often technical, sometimes not',
id: `${this.routing.getAppUrl()}#about`,
link: this.routing.getNamedPath('blog').toRemote,
language: 'en',
image: this.routing.getAssetPath('favicon', 'apple-touch-icon.png').toRemote,
favicon: this.routing.getAssetPath('favicon', 'favicon.ico').toRemote,
copyright: `Copyright (c) ${(new Date).getFullYear()} Garrett Mills. See website for licensing details.`,
updated: posts.whereMax('date').first()?.date,
generator: '',
feedLinks: {
json: this.routing.getNamedPath('blog:json').toRemote,
atom: this.routing.getNamedPath('blog:atom').toRemote,
rss: this.routing.getNamedPath('blog:rss').toRemote,
},
author: {
name: 'Garrett Mills',
email: 'shout@garrettmills.dev',
link: 'https://garrettmills.dev/#about',
},
})
feed.addCategory('Technology')
feed.addCategory('Software Development')
await posts.map(async post => {
feed.addItem({
title: post.title,
date: post.date,
id: this.getUrl(post),
link: this.getUrl(post),
content: await this.renderPost(post.slug),
author: [{
name: 'Garrett Mills',
email: 'shout@garrettmills.dev',
link: 'https://garrettmills.dev/#about',
}],
})
}).awaitAll()
this.cachedFeed = feed
return feed
}
async getSubs(): Promise<Collection<FeedSub>> {
const subs = collect<FeedSub>()
const opml = await this.getOPML()
for ( const row of opml.opml.body[0].outline ) {
if ( !row.outline ) {
subs.push({
category: '(uncategorized)',
name: row['$'].text,
url: row['$'].xmlUrl,
})
continue
}
let category = row['$'].text
if ( category === 'Garrett Mills' ) category += ' (shameless plug)'
for ( const sub of row.outline ) {
subs.push({
category,
name: sub['$'].text,
url: sub['$'].xmlUrl,
})
}
}
return subs
}
async getOPML(): Promise<any> {
const path = appPath('resources', 'assets', 'rss_opml.xml')
const xmlContent = await path.read()
return new Promise<any>((res, rej) => {
xml2js.parseString(xmlContent, (err, result) => {
if ( err ) {
rej(err)
}
res(result)
})
})
}
getUrl(post: BlogPost): string {
const year = post.date.getFullYear()
let month = String(post.date.getMonth() + 1)
if ( month.length < 2 ) month = `0${month}`
let day = String(post.date.getDate())
if ( day.length < 2 ) day = `0${day}`
return `/blog/${year}/${month}/${day}/${post.slug}/`
}
}