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
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}/`
|
|
}
|
|
}
|