Build ETW blog + update cobalt
Some checks reported errors
continuous-integration/drone Build is passing
continuous-integration/drone/promote/production Build was killed

This commit is contained in:
Garrett Mills 2024-06-30 13:46:11 -04:00
parent adac17825b
commit 4686bcc1b8
43 changed files with 2223 additions and 426 deletions

View File

@ -11,7 +11,7 @@
"@atao60/fse-cli": "^0.1.7",
"@extollo/lib": "^0.14.14",
"@types/marked": "^4.0.8",
"@types/node": "^18.11.9",
"@types/node": "^18.19.39",
"@types/xml2js": "^0.4.11",
"any-date-parser": "^1.5.3",
"copyfiles": "^2.4.1",
@ -21,16 +21,16 @@
"lib": "link:@extollo/lib:../extollo/lib",
"marked": "^4.2.12",
"ts-expose-internals": "^4.5.4",
"ts-node": "^10.9.1",
"ts-node": "^10.9.2",
"ts-patch": "^2.0.1",
"ts-to-zod": "^1.8.0",
"typescript": "^4.3.2",
"typescript": "^4.9.5",
"xml2js": "^0.4.23",
"zod": "^3.11.6"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "pnpm run clean && tsc -p tsconfig.json && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/app/resources lib/app/resources",
"build": "pnpm run clean && tsc -p tsconfig.node.json && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/app/resources lib/app/resources && tsc -p tsconfig.client.json",
"clean": "rimraf lib",
"watch": "nodemon --ext js,pug,ts --watch src --exec 'ts-node src/index.ts'",
"app": "ts-node src/index.ts",

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import {OrderDirection} from '@extollo/lib'
import {Awaitable, Constructable, Maybe, OrderDirection, QueryRow} from '@extollo/lib'
export enum FieldType {
text = 'text',
@ -34,6 +34,16 @@ const allResourceActions = [
ResourceAction.update, ResourceAction.delete,
]
export type CobaltAction = {
slug: string,
title: string,
color: string,
icon: string,
type: 'route',
route: string,
overall?: boolean,
}
export type FieldBase = {
key: string,
display: string,
@ -44,6 +54,7 @@ export type FieldBase = {
readonly?: boolean,
helpText?: string,
placeholder?: string,
queryable?: boolean,
hideOn?: {
form?: boolean,
listing?: boolean,
@ -52,22 +63,45 @@ export type FieldBase = {
export type SelectOptions = {display: string, value: any}[]
export type RemoteSelectDefinition = {
displayFrom: string,
valueFrom: string,
source: DataSource,
}
export type FieldDefinition = FieldBase
| FieldBase & { type: FieldType.select, options: SelectOptions }
| FieldBase & { type: FieldType.select, multiple?: boolean, options: SelectOptions }
| FieldBase & { type: FieldType.select } & RemoteSelectDefinition
export interface DataSourceController {
read(): Awaitable<QueryRow[]>
readOne(id: any): Awaitable<Maybe<QueryRow>>
insert(row: QueryRow): Awaitable<QueryRow>
update(id: any, row: QueryRow): Awaitable<QueryRow>
delete(id: any): Awaitable<void>
}
export type DataSource =
{ collection: string }
| { controller: Constructable<DataSourceController> }
| { method: Constructable<() => Awaitable<QueryRow[]>> }
export interface ResourceConfiguration {
key: string,
collection: string,
source: DataSource,
primaryKey: string,
orderField?: string,
orderDirection?: OrderDirection,
generateKeyOnInsert?: () => string|number,
processBeforeInsert?: (row: QueryRow) => Awaitable<QueryRow>,
processAfterRead?: (row: QueryRow) => Awaitable<QueryRow>,
display: {
field?: string,
singular: string,
plural: string,
},
supportedActions: ResourceAction[],
otherActions: CobaltAction[],
fields: FieldDefinition[],
}

View File

@ -3,10 +3,58 @@ import {allResourceActions, FieldType, Renderer, ResourceAction, ResourceConfigu
export default {
resources: [
{
key: 'pendingTransaction',
primaryKey: 'pending_transaction_id',
source: {
collection: 'pending_transactions',
},
display: {
field: 'description',
singular: 'Pending Transaction',
plural: 'Pending Transactions',
},
orderField: 'tx_date',
orderDirection: 'desc',
supportedActions: allResourceActions,
fields: [
{
key: 'account',
display: 'Account',
type: FieldType.select,
required: true,
options: [
{display: 'Discover Card', value: 'Assets:Current Assets:Discover Credit'},
{display: 'Bank MW Checking', value: 'Assets:Current Assets:Bank MW Checking'},
],
},
{
key: 'description',
display: 'Description',
type: FieldType.text,
required: true,
},
{
key: 'amount',
display: 'Amount',
type: FieldType.number,
required: true,
},
{
key: 'tx_date',
display: 'Date',
type: FieldType.date,
renderer: Renderer.date,
required: true,
},
],
},
{
key: 'pageView',
primaryKey: 'page_view_id',
collection: 'page_views',
source: {
collection: 'page_views',
},
display: {
field: 'endpoint',
singular: 'Raw Page View',
@ -53,7 +101,9 @@ export default {
{
key: 'workItem',
primaryKey: 'work_item_id',
collection: 'work_items',
source: {
collection: 'work_items',
},
display: {
field: 'name',
singular: 'Work Item',
@ -94,7 +144,9 @@ export default {
{
key: 'snippet',
primaryKey: 'snippet_id',
collection: 'snippets',
source: {
collection: 'snippets',
},
display: {
field: 'slug',
singular: 'Snippet',
@ -152,7 +204,9 @@ export default {
{
key: 'contactSubmission',
primaryKey: 'contact_submission_id',
collection: 'contact_submissions',
source: {
collection: 'contact_submissions',
},
display: {
field: 'name',
singular: 'Contact Submission',
@ -193,7 +247,9 @@ export default {
{
key: 'goLink',
primaryKey: 'go_link_id',
collection: 'go_links',
source: {
collection: 'go_links',
},
display: {
field: 'short',
singular: 'Go Link',
@ -227,7 +283,9 @@ export default {
key: 'feedPost',
primaryKey: 'feed_post_id',
generateKeyOnInsert: uuid4,
collection: 'feed_posts',
source: {
collection: 'feed_posts',
},
display: {
// field: '',
singular: 'Feed Post',

View File

@ -1,7 +1,7 @@
import {Controller, view, Inject, Injectable, collect, plaintext} from '@extollo/lib'
import {Home} from './Home.controller'
import {Blog as BlogService, BlogPost} from '../../services/Blog.service'
import {FeedPost} from '../../models/FeedPost.model'
import {Blog as BlogService} from '../../services/Blog.service'
import {BlogPost} from '../../services/blog/AbstractBlog.service'
/**
* Blog Controller

View File

@ -0,0 +1,153 @@
import {Controller, view, Injectable, Inject, Routing, redirect, plaintext} from '@extollo/lib'
import {Home} from './Home.controller'
import {FoodBlog as FoodBlogService, FoodBlogPostFrontMatter} from '../../services/blog/FoodBlog.service'
import {BlogPost} from '../../services/blog/AbstractBlog.service'
import {Country, countryNames, isCountry, mapPositions} from '../../services/blog/countries'
/**
* FoodBlog Controller
* ------------------------------------
* Put some description here.
*/
@Injectable()
export class FoodBlog extends Controller {
@Inject()
protected readonly blog!: FoodBlogService
@Inject()
protected readonly routing!: Routing
public async index() {
const home = <Home> this.make(Home)
return view('food:welcome', {
...home.getThemeCSS(),
...(await this.getBlogData()),
posts: (await this.blog.getAllPosts())
.take(10)
.all(),
})
}
public async archive() {
const home = <Home> this.make(Home)
const postsByCountry = (await this.blog.getAllPosts())
.reverse()
.groupBy(p => p.country)
return view('food:archive', {
...home.getThemeCSS(),
...(await this.getBlogData()),
postsByCountry,
postCountries: Object.keys(postsByCountry),
})
}
public async feeds() {
const home = <Home> this.make(Home)
return view('food:feeds', {
...home.getThemeCSS(),
...(await this.getBlogData()),
})
}
public async about() {
const home = <Home> this.make(Home)
return view('food:about', {
...home.getThemeCSS(),
...(await this.getBlogData()),
})
}
public async country() {
const country = this.request.safe('country').string().toUpperCase()
if ( !isCountry(country) ) {
return redirect('/food')
}
const home = <Home> this.make(Home)
return view('food:country', {
...home.getThemeCSS(),
...(await this.getBlogData()),
mapPosition: mapPositions[country],
country,
countryBlurb: await this.blog.getCountryBlurb(country),
posts: (await this.blog.getAllPosts())
.where('country', '=', country)
.all(),
})
}
public async rss() {
const feed = await this.blog.getFeed()
return plaintext(feed.rss2()).contentType('application/rss+xml; charset=UTF-8')
}
public async atom() {
const feed = await this.blog.getFeed()
return plaintext(feed.atom1()).contentType('application/atom+xml; charset=UTF-8')
}
public async json() {
const feed = await this.blog.getFeed()
return plaintext(feed.json1()).contentType('application/feed+json; charset=UTF-8')
}
public async post() {
const home = <Home> this.make(Home)
const slug = this.request.safe('slug').string()
const post = await this.blog.getPost(slug)
if ( !post ) {
return view('food:404', {
...home.getThemeCSS(),
...(await this.getBlogData()),
title: 'Post Not Found',
slug,
})
}
return view('food:post', {
...home.getThemeCSS(),
...(await this.getBlogData()),
post,
mapPosition: mapPositions[post.country],
title: post.title,
renderedPost: await this.blog.renderPost(post.slug),
})
}
private static cachedBlogData: any = undefined
public async getBlogData(): Promise<any> {
if ( FoodBlog.cachedBlogData ) {
return FoodBlog.cachedBlogData
}
const postsByCountry = (await this.blog.getAllPosts())
.reverse()
.groupBy(p => p.country)
const mapValues: any = {}
for ( const country of Object.keys(postsByCountry) ) {
const date = postsByCountry[country][0].date
mapValues[country] = {
posts: postsByCountry[country].length,
visited: date.toLocaleString('default', { month: 'long' }) + ' ' + date.getFullYear(),
link: `/food/c/${country}`,
}
}
return (FoodBlog.cachedBlogData = {
mapValues,
blogCountry: (code: Country): string => `${code} - ${countryNames[code]}`,
blogUrl: (post: BlogPost<FoodBlogPostFrontMatter>): string => this.blog.getUrl(post),
blogDate: (date: Date): string => {
const year = date.getFullYear()
let month = String(date.getMonth() + 1)
if ( month.length < 2 ) month = `0${month}`
let day = String(date.getDate())
if ( day.length < 2 ) day = `0${day}`
return `${year}-${month}-${day}`
},
})
}
}

View File

@ -1,7 +1,7 @@
import {
api,
api, ArrayIterable, AsyncCollection, AsyncPipe, Awaitable,
Builder,
collect,
collect, Collection,
Config,
Controller,
DatabaseService,
@ -11,13 +11,64 @@ import {
HTTPStatus,
Inject,
Injectable,
Iterable,
Maybe,
QueryRow,
} from '@extollo/lib'
import {FieldDefinition, FieldType, ResourceAction, ResourceConfiguration, SelectOptions} from '../../../cobalt'
import {
DataSource,
FieldDefinition,
FieldType,
ResourceAction,
ResourceConfiguration,
SelectOptions,
} from '../../../cobalt'
const parser = require('any-date-parser')
class AwaitableIterable<T> extends Iterable<T> {
public static lift<TInner>(source: Awaitable<Iterable<TInner>>): AwaitableIterable<TInner> {
return new AwaitableIterable<TInner>(source)
}
private resolved?: Iterable<T>
constructor(
protected source: Awaitable<Iterable<T>>
) {
super()
}
at(i: number): Promise<Maybe<T>> {
return this.resolve()
.then(x => x.at(i))
}
clone(): Iterable<T> {
return AwaitableIterable.lift(
this.resolve()
.then(x => x.clone())
)
}
range(start: number, end: number): Promise<Collection<T>> {
return this.resolve()
.then(x => x.range(start, end))
}
count(): Promise<number> {
return this.resolve()
.then(x => x.count())
}
private async resolve(): Promise<Iterable<T>> {
if ( !this.resolved ) {
this.resolved = await this.source
}
return this.resolved
}
}
@Injectable()
export class ResourceAPI extends Controller {
@Inject()
@ -26,8 +77,8 @@ export class ResourceAPI extends Controller {
@Inject()
protected readonly db!: DatabaseService
public configure(key: string) {
const config = this.getResourceConfig(key)
public async configure(key: string) {
const config = await this.getResourceConfig(key)
if ( config ) {
return api.one(config)
}
@ -36,46 +87,73 @@ export class ResourceAPI extends Controller {
}
public async read(key: string) {
const config = this.getResourceConfigOrFail(key)
const config = await this.getResourceConfigOrFail(key)
this.checkAction(config, ResourceAction.read)
const result = await this.make<Builder>(Builder)
.select(config.primaryKey, ...config.fields.map(x => x.key))
.from(config.collection)
.limit(500)
.orderBy(config.orderField || config.primaryKey, config.orderDirection || 'asc')
.connection(this.db.get())
.get()
.all()
let result: QueryRow[]
if ( 'controller' in config.source ) {
const controller = config.source.controller.apply(this.request)
result = await controller.read()
} else if ( 'method' in config.source ) {
const method = config.source.method.apply(this.request)
result = await method()
} else {
result = await this.make<Builder>(Builder)
.select(config.primaryKey, ...config.fields.filter(x => x.queryable !== false).map(x => x.key))
.from(config.source.collection)
.limit(500)
.orderBy(config.orderField || config.primaryKey, config.orderDirection || 'asc')
.connection(this.db.get())
.get()
.all()
}
if ( config.processAfterRead ) {
result = await Promise.all(result.map(config.processAfterRead))
}
return api.many(result)
}
public async readOne(key: string, id: number|string) {
const config = this.getResourceConfigOrFail(key)
const config = await this.getResourceConfigOrFail(key)
this.checkAction(config, ResourceAction.readOne)
const row = await this.make<Builder>(Builder)
.select(config.primaryKey, ...config.fields.map(x => x.key))
.from(config.collection)
.where(config.primaryKey, '=', id)
.limit(1)
.connection(this.db.get())
.first()
let row: Maybe<QueryRow>
if ( 'controller' in config.source ) {
const controller = config.source.controller.apply(this.request)
row = await controller.readOne(id)
} else if ( 'method' in config.source ) {
const method = config.source.method.apply(this.request)
row = collect(await method())
.firstWhere(config.primaryKey, '=', key)
} else {
row = await this.make<Builder>(Builder)
.select(config.primaryKey, ...config.fields.filter(x => x.queryable !== false).map(x => x.key))
.from(config.source.collection)
.where(config.primaryKey, '=', id)
.limit(1)
.connection(this.db.get())
.first()
}
if ( !row ) {
throw new HTTPError(HTTPStatus.NOT_FOUND)
}
if ( config.processAfterRead ) {
row = await config.processAfterRead(row)
}
return api.one(row)
}
public async create(key: string, dataContainer: DataContainer) {
const config = this.getResourceConfigOrFail(key)
const config = await this.getResourceConfigOrFail(key)
this.checkAction(config, ResourceAction.create)
// Load input values
const queryRow: QueryRow = {}
let queryRow: QueryRow = {}
for ( const field of config.fields ) {
const value = dataContainer.input(field.key)
if ( field.required && typeof value === 'undefined' ) {
@ -89,20 +167,32 @@ export class ResourceAPI extends Controller {
queryRow[config.primaryKey] = config.generateKeyOnInsert()
}
if ( config.processBeforeInsert ) {
queryRow = await config.processBeforeInsert(queryRow)
}
// Create insert query
const result = await this.make<Builder>(Builder)
.table(config.collection)
.returning(config.primaryKey, ...config.fields.map(x => x.key))
.connection(this.db.get())
.insert(queryRow)
.then(x => x.rows.first())
let result: Maybe<QueryRow>
if ( 'controller' in config.source ) {
const controller = config.source.controller.apply(this.request)
result = await controller.insert(queryRow)
} else if ( 'method' in config.source ) {
throw new Error('The "method" source type does not support creating records.')
} else {
result = await this.make<Builder>(Builder)
.table(config.source.collection)
.returning(config.primaryKey, ...config.fields.map(x => x.key))
.connection(this.db.get())
.insert(queryRow)
.then(x => x.rows.first())
}
// Return result
return api.one(result)
}
public async update(key: string, id: number|string, dataContainer: DataContainer) {
const config = this.getResourceConfigOrFail(key)
const config = await this.getResourceConfigOrFail(key)
this.checkAction(config, ResourceAction.update)
// Load input values
@ -120,31 +210,46 @@ export class ResourceAPI extends Controller {
await this.readOne(key, id)
// Create update query
const result = await this.make<Builder>(Builder)
.table(config.collection)
.returning(config.primaryKey, ...config.fields.map(x => x.key))
.connection(this.db.get())
.where(config.primaryKey, '=', id)
.update(queryRow)
.then(x => x.rows.first())
let result: Maybe<QueryRow>
if ( 'controller' in config.source ) {
const controller = config.source.controller.apply(this.request)
result = await controller.update(id, queryRow)
} else if ( 'method' in config.source ) {
throw new Error('The "method" source type does not support updating records.')
} else {
result = await this.make<Builder>(Builder)
.table(config.source.collection)
.returning(config.primaryKey, ...config.fields.map(x => x.key))
.connection(this.db.get())
.where(config.primaryKey, '=', id)
.update(queryRow)
.then(x => x.rows.first())
}
// Return the result
return api.one(result)
}
public async delete(key: string, id: number|string) {
const config = this.getResourceConfigOrFail(key)
const config = await this.getResourceConfigOrFail(key)
this.checkAction(config, ResourceAction.delete)
// Make sure the row exists
await this.readOne(key, id)
// Execute the query
await this.make<Builder>(Builder)
.table(config.collection)
.connection(this.db.get())
.where(config.primaryKey, '=', id)
.delete()
if ( 'controller' in config.source ) {
const controller = config.source.controller.apply(this.request)
await controller.delete(id)
} else if ( 'method' in config.source ) {
throw new Error('The "method" source type does not support deleting records.')
} else {
await this.make<Builder>(Builder)
.table(config.source.collection)
.connection(this.db.get())
.where(config.primaryKey, '=', id)
.delete()
}
return { success: true }
}
@ -169,7 +274,7 @@ export class ResourceAPI extends Controller {
throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`)
}
if ( isNaN(cast) ) {
if ( value && value !== 0 && isNaN(cast) ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST, `Field must be a number: ${key}`)
}
return cast
@ -179,7 +284,7 @@ export class ResourceAPI extends Controller {
throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`)
}
if ( isNaN(cast) ) {
if ( value && value !== 0 && isNaN(cast) ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST, `Field must be a number: ${key}`)
}
return cast
@ -202,10 +307,12 @@ export class ResourceAPI extends Controller {
throw new HTTPError(HTTPStatus.BAD_REQUEST, `Invalid e-mail address format: ${key}`)
}
return cast
} else if ( type === FieldType.select && hasOwnProperty(fieldDef, 'options') ) {
} else if ( type === FieldType.select && 'options' in fieldDef ) {
const options = collect(fieldDef.options as SelectOptions)
if ( options.pluck('value').includes(value) ) {
return value
const selectedValues = Array.isArray(value) ? value : [value]
const validOptions = options.pluck('value').intersect(selectedValues)
if ( validOptions.isNotEmpty() ) {
return fieldDef.multiple ? validOptions.all() : validOptions.get(0)
}
if ( required ) {
@ -216,20 +323,76 @@ export class ResourceAPI extends Controller {
}
}
protected getResourceConfigOrFail(key: string): ResourceConfiguration {
const config = this.getResourceConfig(key)
protected async getResourceConfigOrFail(key: string): Promise<ResourceConfiguration> {
const config = await this.getResourceConfig(key)
if ( !config ) {
throw new HTTPError(HTTPStatus.NOT_FOUND)
}
return config
}
protected getResourceConfig(key: string): Maybe<ResourceConfiguration> {
protected async getResourceConfig(key: string): Promise<Maybe<ResourceConfiguration>> {
const configs = this.config.get('cobalt.resources') as ResourceConfiguration[]
for ( const config of configs ) {
if ( config.key === key ) {
return config
let config: Maybe<ResourceConfiguration> = undefined
for ( const match of configs ) {
if ( match.key === key ) {
config = match
break
}
}
if ( !config ) {
return config
}
// Resolve `select` fields with remote sources
config.fields = await collect(config.fields)
.map(async field => {
if ( field.type === FieldType.select && 'source' in field ) {
return {
...field,
options: await this.readSource(field.source)
.map(qr => ({
display: qr[field.displayFrom],
value: qr[field.valueFrom],
}))
.then(c => c.all())
} as FieldDefinition
}
return field
})
.awaitAll()
.then(x => x.all())
return config
}
protected readSource(source: DataSource): AsyncCollection<QueryRow> {
let iter: Iterable<QueryRow>
if ( 'controller' in source ) {
iter = AwaitableIterable.lift(
AsyncPipe.wrap(source.controller.apply(this.request))
.tap(controller => controller.read())
.tap(rows => new ArrayIterable(rows))
.resolve()
)
} else if ( 'method' in source ) {
iter = AwaitableIterable.lift(
AsyncPipe.wrap(source.method.apply(this.request))
.tap(method => method())
.tap(rows => new ArrayIterable(rows))
.resolve()
)
} else {
iter = this.make<Builder>(Builder)
.from(source.collection)
.limit(500)
.connection(this.db.get())
.getResultIterable()
}
return new AsyncCollection(iter)
}
}

View File

@ -11,6 +11,7 @@ import {RateLimit} from '../middlewares/RateLimit.middleware'
import {SiteTheme} from '../middlewares/SiteTheme.middleware'
import {Blog} from '../controllers/Blog.controller'
import {MarkMark} from '../controllers/MarkMark.controller'
import {FoodBlog} from '../controllers/FoodBlog.controller'
Route.endpoint('options', '**')
.handledBy(() => api.one({}))
@ -63,6 +64,43 @@ Route
.pre(SiteTheme)
.calls<Blog>(Blog, blog => blog.post)
Route.get('/food')
.pre(SiteTheme)
.calls<FoodBlog>(FoodBlog, fb => fb.index)
.alias('etw')
Route.get('/food/about')
.pre(SiteTheme)
.calls<FoodBlog>(FoodBlog, fb => fb.about)
Route.get('/food/c/:country')
.pre(SiteTheme)
.calls<FoodBlog>(FoodBlog, fb => fb.country)
Route.get('/food/archive')
.pre(SiteTheme)
.calls<FoodBlog>(FoodBlog, fb => fb.archive)
Route.get('/food/feeds')
.pre(SiteTheme)
.calls<FoodBlog>(FoodBlog, fb => fb.feeds)
Route.get('/food/rss2.xml')
.calls<FoodBlog>(FoodBlog, fb => fb.rss)
.alias('food:rss')
Route.get('/food/atom.xml')
.calls<FoodBlog>(FoodBlog, fb => fb.atom)
.alias('food:atom')
Route.get('/food/json.json')
.calls<FoodBlog>(FoodBlog, fb => fb.json)
.alias('food:json')
Route.get('/food/*/*/*/:slug')
.pre(SiteTheme)
.calls<FoodBlog>(FoodBlog, fb => fb.post)
Route.post('/contact')
.pre(RateLimit)
.pre(SiteTheme)

View File

@ -0,0 +1,52 @@
import {Injectable, Migration, Inject, DatabaseService, FieldType} from '@extollo/lib'
/**
* CreatePendingTransactionsTableMigration
* ----------------------------------
* Put some description here.
*/
@Injectable()
export default class CreatePendingTransactionsTableMigration extends Migration {
@Inject()
protected readonly db!: DatabaseService
/**
* Apply the migration.
*/
async up(): Promise<void> {
const schema = this.db.get().schema()
const table = await schema.table('pending_transactions')
table.primaryKey('pending_transaction_id').required()
table.column('account')
.type(FieldType.varchar)
.required()
table.column('description')
.type(FieldType.varchar)
.required()
table.column('amount')
.type(FieldType.doublePrecision)
.required()
table.column('tx_date')
.type(FieldType.date)
.required()
await schema.commit(table)
}
/**
* Undo the migration.
*/
async down(): Promise<void> {
const schema = this.db.get().schema()
const table = await schema.table('pending_transactions')
table.dropIfExists()
await schema.commit(table)
}
}

View File

@ -0,0 +1,29 @@
import {Field, FieldType, Injectable, Model} from '@extollo/lib'
export type TxAccount = 'Assets:Current Assets:Discover Credit' | 'Assets:Current Assets:Bank MW Checking'
/**
* PendingTransaction Model
* -----------------------------------
* Temporarily tracks transactions before they are flushed to GnuCash
*/
@Injectable()
export class PendingTransaction extends Model<PendingTransaction> {
protected static table = 'pending_transactions'
protected static key = 'pending_transaction_id'
@Field(FieldType.serial, 'pending_transaction_id')
protected id?: number
@Field(FieldType.varchar)
public account!: TxAccount
@Field(FieldType.varchar)
public description!: string
@Field(FieldType.doublePrecision)
public amount!: number
@Field(FieldType.date)
public txDate!: Date
}

View File

@ -0,0 +1,61 @@
import {
AppValue,
BaseAppDataMap,
BaseEventMap,
} from './types'
import {BehaviorSubject} from './BehaviorSubject'
export type BaseApp = App<BaseEventMap, BaseAppDataMap>
export const app = (): BaseApp|undefined => {
return (window as any).exAppInstance
}
export class App<TEventMap extends BaseEventMap, TDataMap extends BaseAppDataMap> {
private data: Partial<TDataMap> = {}
private events: Partial<{
[EventKey in keyof TEventMap]: BehaviorSubject<TEventMap[EventKey]>
}> = {}
constructor() {
(window as any).exAppInstance = this
}
event<TEvent extends keyof TEventMap>(event: TEvent): BehaviorSubject<TEventMap[TEvent]> {
return this.events[event] || (this.events[event] = new BehaviorSubject)
}
async fire<TEvent extends keyof TEventMap>(event: TEvent, arg: TEventMap[TEvent]): Promise<void> {
this.events[event]?.next(arg)
}
initialize(data: TDataMap) {
this.data = data
}
get(key: keyof TDataMap): AppValue|undefined {
return this.data[key]
}
set<TKey extends keyof TDataMap>(key: TKey, value: TDataMap[TKey]): (typeof value) {
return (this.data[key] = value)
}
url(path?: string): string {
if ( !path ) path = ''
if ( !path.startsWith('/') ) path = `/${path}`
let url = this.get('appUrl')
if ( url.endsWith('/') ) url = url.slice(0, -1)
return `${url}${path}`
}
host(): string {
let url = this.url()
if ( url.startsWith('https://') ) url = url.substring(8)
else if ( url.startsWith('http://') ) url = url.substring(7)
return url.split('/')[0].split(':')[0]
}
}

View File

@ -0,0 +1,212 @@
// Yoinked straight from @extollo/lib
/**
* Base error used to trigger an unsubscribe action from a subscriber.
* @extends Error
*/
export class UnsubscribeError extends Error {}
/**
* Thrown when a closed observable is pushed to.
* @extends Error
*/
export class CompletedObservableError extends Error {
constructor() {
super('This observable can no longer be pushed to, as it has been completed.')
}
}
/**
* Type of a basic subscriber function.
*/
export type SubscriberFunction<T> = (val: T) => any
/**
* Type of a basic subscriber function that handles errors.
*/
export type SubscriberErrorFunction = (error: Error) => any
/**
* Type of a basic subscriber function that handles completed events.
*/
export type SubscriberCompleteFunction<T> = (val?: T) => any
/**
* Subscribers that define multiple handler methods.
*/
export type ComplexSubscriber<T> = {
next?: SubscriberFunction<T>,
error?: SubscriberErrorFunction,
complete?: SubscriberCompleteFunction<T>,
}
/**
* Subscription to a behavior subject.
*/
export type Subscription<T> = SubscriberFunction<T> | ComplexSubscriber<T>
/**
* Object providing helpers for unsubscribing from a subscription.
*/
export type Unsubscribe = { unsubscribe: () => void }
/**
* A stream-based state class.
*/
export class BehaviorSubject<T> {
/**
* Subscribers to this subject.
* @type Array<ComplexSubscriber>
*/
protected subscribers: ComplexSubscriber<T>[] = []
/**
* True if this subject has been marked complete.
* @type boolean
*/
protected subjectIsComplete = false
/**
* The current value of this subject.
*/
protected currentValue?: T
/**
* True if any value has been pushed to this subject.
* @type boolean
*/
protected hasPush = false
/**
* Register a new subscription to this subject.
* @param {Subscription} subscriber
* @return Unsubscribe
*/
public subscribe(subscriber: Subscription<T>): Unsubscribe {
if ( typeof subscriber === 'function' ) {
this.subscribers.push({ next: subscriber })
} else {
this.subscribers.push(subscriber)
}
return {
unsubscribe: () => {
this.subscribers = this.subscribers.filter(x => x !== subscriber)
},
}
}
/**
* Cast this subject to a promise, which resolves on the output of the next value.
* @return Promise
*/
public toPromise(): Promise<T> {
return new Promise((resolve, reject) => {
const { unsubscribe } = this.subscribe({
next: (val: T) => {
resolve(val)
unsubscribe()
},
error: (error: Error) => {
reject(error)
unsubscribe()
},
complete: (val?: T) => {
if ( typeof val !== 'undefined' ) {
resolve(val)
}
unsubscribe()
},
})
})
}
/**
* Push a new value to this subject. The promise resolves when all subscribers have been pushed to.
* @param val
* @return Promise<void>
*/
public async next(val: T): Promise<void> {
if ( this.subjectIsComplete ) {
throw new CompletedObservableError()
}
this.currentValue = val
this.hasPush = true
for ( const subscriber of this.subscribers ) {
if ( subscriber.next ) {
try {
await subscriber.next(val)
} catch (e) {
if ( e instanceof UnsubscribeError ) {
this.subscribers = this.subscribers.filter(x => x !== subscriber)
} else if (subscriber.error && e instanceof Error) {
await subscriber.error(e)
} else {
throw e
}
}
}
}
}
/**
* Push the given array of values to this subject in order.
* The promise resolves when all subscribers have been pushed to for all values.
* @param {Array} vals
* @return Promise<void>
*/
public async push(vals: T[]): Promise<void> {
if ( this.subjectIsComplete ) {
throw new CompletedObservableError()
}
await Promise.all(vals.map(val => this.next(val)))
}
/**
* Mark this subject as complete.
* The promise resolves when all subscribers have been pushed to.
* @param [finalValue] - optionally, a final value to set
* @return Promise<void>
*/
public async complete(finalValue?: T): Promise<void> {
if ( this.subjectIsComplete ) {
throw new CompletedObservableError()
}
if ( typeof finalValue === 'undefined' ) {
finalValue = this.value()
} else {
this.currentValue = finalValue
}
for ( const subscriber of this.subscribers ) {
if ( subscriber.complete ) {
try {
await subscriber.complete(finalValue)
} catch (e) {
if ( subscriber.error && e instanceof Error ) {
await subscriber.error(e)
} else {
throw e
}
}
}
}
this.subjectIsComplete = true
}
/**
* Get the current value of this subject.
*/
public value(): T | undefined {
return this.currentValue
}
/**
* True if this subject is marked as complete.
* @return boolean
*/
public isComplete(): boolean {
return this.subjectIsComplete
}
}

View File

@ -0,0 +1,14 @@
import {app, BaseApp} from './App'
export abstract class Component extends HTMLElement {
protected app(): BaseApp|undefined {
return app()
}
constructor() {
super()
this.initialize()
}
protected initialize(): void {}
}

View File

@ -0,0 +1,31 @@
import {Component} from '../Component'
import {MessageAlertEvent} from '../types'
export class MessageContainer extends Component {
public activeAlerts: MessageAlertEvent[] = []
protected initialize() {
this.attachShadow({ mode: 'open' })
this.app()
?.event('message.alert')
.subscribe(alert => {
this.activeAlerts.push(alert)
this.render()
})
this.render()
}
render() {
if ( !this.shadowRoot ) {
return
}
this.shadowRoot.innerHTML = `
<div class="messages">
${this.activeAlerts.map(x => '<div>' + x + '</div>')}
</div>
`;
}
}

View File

@ -0,0 +1,5 @@
import {MessageContainer} from './MessageContainer'
export const registerComponents = () => {
customElements.define('ex-messages', MessageContainer)
}

View File

@ -0,0 +1,4 @@
export * from './App'
export * from './types'
export * from './util'

View File

@ -0,0 +1,28 @@
export type AppValue = any
export type EventMap = {[key: string]: unknown}
export type EventListenerReturn = unknown
export type BaseEventMap = EventMap & {
'message.alert': MessageAlertEvent,
'message.modal': MessageModalEvent,
}
export type AppDataMap = {[key: string]: unknown}
export type BaseAppDataMap = AppDataMap & {
appUrl: string
}
export type MessageAlertEvent = {
severity: 'error' | 'warning' | 'info' | 'success'
message: string
timeoutSec?: number
onDismiss?: () => unknown
}
export type MessageModalEvent = {
title: string
message: string
// fixme: buttons, inputs
}

View File

@ -0,0 +1,52 @@
export namespace Ex {
export const dateToDisplay = (d: Date): string => {
let month = `${d.getMonth() + 1}`
if ( month.length < 2 ) month = `0${month}`
let date = `${d.getDate()}`
if ( date.length < 2 ) date = `0${date}`
return `${d.getFullYear()}-${month}-${date}`
}
export const dateToInput = (d: Date): string => {
let month = `${d.getMonth() + 1}`
if ( month.length < 2 ) month = `0${month}`
let date = `${d.getDate()}`
if ( date.length < 2 ) date = `0${date}`
let hours = `${d.getHours()}`
if ( hours.length < 2 ) hours = `0${date}`
let minutes = `${d.getMinutes()}`
if ( minutes.length < 2 ) minutes = `0${date}`
return `${d.getFullYear()}-${month}-${date}T${hours}:${minutes}`
}
export const uuid = (): string => {
// @ts-ignore
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
)
}
export const debounce = <TArgs extends [], TReturn>(handler: (...args: TArgs) => TReturn, delayMs: number = 500): ((...args: TArgs) => Promise<TReturn>) => {
let timeout: any = null
return (...args: TArgs): Promise<TReturn> => {
clearTimeout(timeout)
return new Promise<TReturn>((res, rej) => {
timeout = setTimeout(() => {
try {
res(handler(...args))
} catch (e) {
rej(e)
}
}, delayMs)
})
}
}
export class Subject<T> {
}
}

View File

@ -32,6 +32,7 @@ export class Resource {
this.key = key
this.configuration = {
supportedActions: [],
otherActions: [],
}
}
@ -51,6 +52,10 @@ export class Resource {
return this.configuration.supportedActions
}
getOtherActions() {
return this.configuration.otherActions || []
}
supports(action) {
return this.getSupportedActions().includes(action)
}

View File

@ -60,6 +60,15 @@ const template = `
:placeholder="field.placeholder || ''"
:readonly="mode === 'view' || field.readonly"
>
<select
class="form-control"
size="10"
v-if="field.type === 'select'"
:multiple="!!field.multiple"
v-model="data[field.key]"
>
<option v-for="option of field.options" :value="option.value">{{ option.display }}</option>
</select>
<cobalt-monaco
v-if="field.type === 'html' && !(mode === 'view' || field.readonly)"
syntax="html"

View File

@ -119,7 +119,7 @@ export class ListingComponent extends Component {
action: 'update',
defer: true,
})
} else if ( !reload && this.resource.supports(ResourceActions.read) ) {
} else if ( !reload && this.resource.supports(ResourceActions.readOne) ) {
this.actions.push({
title: 'View',
color: 'primary',
@ -143,6 +143,13 @@ export class ListingComponent extends Component {
})
}
for ( const action of this.resource.getOtherActions() ) {
this.actions.push({
resource: this.resource.key,
...action,
})
}
this.rows = await this.resource.read()
this.rows.forEach((row, idx) => row.idx = idx)

View File

@ -14,6 +14,7 @@ export class ActionService {
async perform(action, data, onComplete = () => {}) {
if ( action.type === 'back' ) this.goBack()
if ( action.type === 'resource' ) await this.handleResourceAction(action, data, onComplete)
if ( action.type === 'route' ) await this.handleRouteAction(action, data, onComplete)
}
async handleResourceAction(action, data, onComplete) {
@ -60,6 +61,25 @@ export class ActionService {
location.assign(Session.get().url(`dash/cobalt/form/${key}${id ? '/' + id : ''}${view ? '/view' : ''}`))
}
async handleRouteAction(action, data, onComplete = () => {}) {
let route = Session.get().url(action.route)
route = this.replacePlaceholders(route, { resourceKey: action.resource })
route = this.replacePlaceholders(route, data)
location.assign(route)
}
replacePlaceholders(str, data) {
for ( const key in data ) {
if ( !data.hasOwnProperty(key) ) {
continue
}
str = String(str).replaceAll(':' + key, data[key])
}
return str
}
goBack() {
history.back()
}

View File

@ -280,6 +280,18 @@ pre code {
max-width: min(70%, 1000px);
min-height: 100vh;
}
.inner.full {
max-width: 100%;
min-width: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.centered-content {
max-width: min(70%, 1000px);
width: 100%;
}
.theme-hide {
display: none !important;
}
@ -745,6 +757,11 @@ section#auth h3 {
.inline-nav .button:hover {
background-color: var(--c-hero);
}
.inline-nav .sep {
border-right: 3px solid var(--c-font);
margin-right: 40px;
}
/*.inline-nav li {
font-family: TheWobliy, serif;
background-color: var(--c-font);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
French cuisine has evolved from medieval feasts to today's refined gastronomy, influenced by figures like La Varenne and Escoffier. It balances regional specialties such as Provences bouillabaisse and Lorraines quiche with modern innovations by chefs like Alain Ducasse and Dominique Crenn. Known for its rich flavors and sophisticated techniques, French cuisine continues to shape global culinary trends, blending tradition with contemporary flair.

View File

View File

@ -47,6 +47,14 @@ head
a.nav-link(href=route('dash/cobalt/listing/pageView'))
i.fas.fa-fw.fa-chart-bar
span Raw Page Views
hr.sidebar-divider
.sidebar-heading Utilities
li.nav-item
a.nav-link(href=route('dash/cobalt/listing/pendingTransaction'))
i.fas.fa-fw.fa-dollar-sign
span Pending Transactions
#content-wrapper.d-flex.flex-column
#content
nav.navbar.navbar-expand.navbar-light.bg-white.topbar.mb-4.static-top.shadow

View File

@ -0,0 +1,24 @@
extends page
block append food-content
.centered-content
h2(style="font-size: 36pt") About ETW
img(src="/assets/img/profile.jpg" width=200 height=200 style='border-radius: 20px; margin-top: 10px;')
p Hi, there! I'm Garrett Mills. I'm a software engineer with a passion for food.
p Growing up in the United States, I have a very western-centric exposure to food. Over the past few years, I've started exposing myself to an increasingly broad range of international cuisine -- especially through my own cooking.
p The biggest challenge I've run into is just how immense the world of food is, which can make it hard to <i>decide on</i> new cuisines to try. Eating the World is my attempt to better do this.
p The premise behind Eating the World is simple: each month, my partner Faith and I will choose a new country and spend a few days researching the food. Then, each week of the month I'll cook a new dish from that country.
p My hope is that this will provide some much needed direction for my learning journey, and will help me discover even more cuisines. As you might imagine, this is a <i>very</i> long-term project.
h3 Upcoming Countries
ul
li July 2024 - France
li August 2024 - Ethiopia
li September 2024 - Colombia
li October 2024 - Pakistan

View File

@ -0,0 +1,13 @@
extends page
block append food-content
.centered-content
h2(style="font-size: 36pt") Exploration Archive
.recent-posts
each country in postCountries
h3(style="background-color: var(--c-font)") #{blogCountry(country)} <a href="/food/c/#{country}" style="text-decoration: none">🔗</a>
each post in postsByCountry[country]
.post-tile
.date #{blogDate(post.date)}
a.title(href=blogUrl(post)) #{post.title}

View File

@ -0,0 +1,13 @@
extends page
block append food-content
.centered-content
h2(style="font-size: 36pt") #{blogCountry(country)}
p !{countryBlurb}
.recent-posts
each post in posts
.post-tile
.date #{blogDate(post.date)}
a.title(href=blogUrl(post)) #{post.title}

View File

@ -0,0 +1,6 @@
extends page
block append food-content
.centered-content
p This blog is available via <a href="#{named('food:rss')}">RSS</a>, <a href="#{named('food:atom')}">Atom</a>, or <a href="#{named('food:json')}">JSON</a> syndication.
p I believe very strongly that open syndication, like RSS, is <i>good for the web</i>. You can read more about it <a href="#{route('/blog/feeds')}">here</a>.

View File

@ -0,0 +1,108 @@
extends ../template_70s
block title
if title
title #{title} | Eating the World
else
title Eating the World
block content
.container#top
.inner.full
.centered-content
h2(style='font-size: 26pt; padding-top: 20px') Eating the World
ul.inline-nav
li
a.button(href=route('/food')) Home
li
a.button(href=route('/food/about')) About ETW
li
a.button(href=route('/food/archive')) Archive
li
a.button(href=route('/food/feeds')) RSS
li.sep
li
a.button(href=route('/')) Main Site
if mapPosition
div(style="margin-top: 30px; background: var(--c-background-offset-2); width: 100%; display: flex; flex-direction: row; justify-content: center;")
.centered-content
div#miniMap
block food-content
block contact-text
.centered-content(style="margin-top: 100px")
p Questions, comments, or feedback? <a href="/#contact">Get in touch.</a>
block map
section#map(style='width: 100%; margin: 0; padding: 0; background: var(--c-background-offset-2);')
div#svgMap(style='')
block append style
link(rel='stylesheet' href=asset('map/svgMap.min.css'))
style.
.svgMap-tooltip {
z-index: 200;
}
.svgMap-map-wrapper {
background: rgba(0, 0, 0, 0);
}
.svgMap-map-wrapper .svgMap-country {
stroke: var(--c-hero);
}
block append script
script(src=asset('map/svg-pan-zoom.min.js'))
script(src=asset('map/svgMap.min.js'))
script.
new svgMap({
targetElementID: 'svgMap',
mouseWheelZoomWithKey: true,
noDataText: 'Not yet explored',
flagType: 'emoji',
colorNoData: 'var(--c-background)',
data: {
data: {
posts: {
name: 'Explorations logged',
format: '{0} posts',
},
visited: {
name: 'Last explored',
format: '{0}',
},
},
applyData: 'posts',
values: !{JSON.stringify(mapValues)},
},
})
if mapPosition
script.
new svgMap({
targetElementID: 'miniMap',
mouseWheelZoomEnabled: false,
noDataText: '',
flagType: 'emoji',
colorNoData: 'var(--c-background)',
initialZoom: #{mapPosition.zoom},
initialPan: {
x: #{mapPosition.x},
y: #{mapPosition.y}
},
data: {
data: {
current: {
name: '',
format: 'Cuisine being explored',
},
},
applyData: 'current',
values: {
#{mapPosition.country}: {current: 1}
},
},
})

View File

@ -0,0 +1,31 @@
extends page
block meta
meta(charset='utf-8')
meta(http-equiv='X-UA-Compatible' content='IE=edge')
meta(name='HandheldFriendly' content='True')
meta(name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1')
meta(name='description' content=post.title)
meta(property='og:type' content='article')
meta(property='og:url' content=blogUrl(post))
meta(property='og:title' content=post.title)
meta(property='og:site_name' content='Eating the World')
meta(property='og:description' content=post.title)
meta(property='og:locale' content='en_US')
// fixme: description, og:image
meta(property='article:published_time' content=post.date.toISOString())
meta(property='article:modified_time' content=post.date.toISOString())
meta(property='article:author' content='Garrett Mills')
// each tag in post.tags
// meta(property='article:tag' content=tag)
meta(name='twitter:card' content='summary')
// fixme: twitter:image
block food-content
.centered-content
h2.post-title #{post.title}
p.post-byline 🌎 <a href="/food/c/#{post.country}">#{blogCountry(post.country)}</a>&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;by Garrett Mills on #{blogDate(post.date)}
.post-content !{renderedPost}

View File

@ -0,0 +1,19 @@
extends page
block append food-content
.centered-content
p Welcome! Eating the World is a lifetime exploration of international food. See our progress on the map below.
section#map(style='width: 100%; margin: 0; margin-top: 10px; padding: 0; background: var(--c-background-offset-2);')
div#svgMap(style='')
.centered-content
h2(style="font-size: 36pt") Most Recent Journeys
.recent-posts
each post in posts
.post-tile
.date #{blogCountry(post.country)}
a.title(href=blogUrl(post)) #{post.title}
block map

View File

@ -1,30 +1,14 @@
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
}
import {
Singleton,
appPath,
Collection,
Maybe,
collect,
Awaitable,
} from '@extollo/lib'
import {AbstractBlog, BlogBackend, BlogPostFrontMatter, isBlogPostFrontMatter} from './blog/AbstractBlog.service'
export interface FeedSub {
category: string
@ -33,121 +17,7 @@ export interface FeedSub {
}
@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
}
export class Blog extends AbstractBlog<BlogPostFrontMatter> {
async getSubs(): Promise<Collection<FeedSub>> {
const subs = collect<FeedSub>()
const opml = await this.getOPML()
@ -188,12 +58,49 @@ export class Blog {
})
}
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}/`
protected createFeed(lastUpdated: Maybe<Date>): Awaitable<RSSFeed.Feed> {
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: lastUpdated,
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')
return feed
}
protected getBackend(): BlogBackend<BlogPostFrontMatter> {
return {
routePrefix: '/blog',
resourcePath: ['blog-posts'],
author: {
name: 'Garrett Mills',
email: 'shout@garrettmills.dev',
link: 'https://garrettmills.dev/#about',
},
}
}
protected isValidFrontMatter(what: unknown): what is BlogPostFrontMatter {
return isBlogPostFrontMatter(what)
}
}

View File

@ -0,0 +1,152 @@
import * as fs from 'fs/promises'
import * as matter from 'gray-matter'
import * as marked from 'marked'
import * as RSSFeed from 'feed'
import {
appPath,
Collection,
Inject,
Maybe,
collect,
Logging,
hasOwnProperty,
Routing,
Injectable, Awaitable,
} 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<TFrontMatter extends BlogPostFrontMatter = BlogPostFrontMatter> = TFrontMatter & {
file: string
markdown: string
}
export interface BlogBackend<TFrontMatter extends BlogPostFrontMatter> {
routePrefix: string,
resourcePath: string[],
author: {
name: string,
email: string,
link: string,
},
}
@Injectable()
export abstract class AbstractBlog<TFrontMatter extends BlogPostFrontMatter> {
@Inject()
protected readonly logging!: Logging
@Inject()
protected readonly routing!: Routing
protected posts: Maybe<Collection<BlogPost<TFrontMatter>>>
protected postRenderCache: Record<string, string> = {}
protected cachedFeed: Maybe<RSSFeed.Feed>
protected abstract getBackend(): BlogBackend<TFrontMatter>
protected abstract isValidFrontMatter(what: unknown): what is TFrontMatter
async getAllPosts(): Promise<Collection<BlogPost<TFrontMatter>>> {
if ( !this.posts ) {
this.posts = collect()
const path = appPath('resources', ...this.getBackend().resourcePath)
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 ( !this.isValidFrontMatter(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<TFrontMatter>>> {
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
}
protected abstract createFeed(lastUpdated: Maybe<Date>): Awaitable<RSSFeed.Feed>
async getFeed(): Promise<RSSFeed.Feed> {
if ( this.cachedFeed ) {
return this.cachedFeed
}
const posts = await this.getAllPosts()
const feed = await this.createFeed(posts.whereMax('date').first()?.date)
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: [this.getBackend().author],
})
}).awaitAll()
this.cachedFeed = feed
return feed
}
getUrl(post: BlogPost<TFrontMatter>): 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}`
let prefix = this.getBackend().routePrefix
if ( !prefix.startsWith('/') ) prefix = `/${prefix}`
return `${prefix}/${year}/${month}/${day}/${post.slug}/`
}
}

View File

@ -0,0 +1,79 @@
import * as RSSFeed from 'feed'
import {appPath, Awaitable, hasOwnProperty, Maybe, Singleton} from '@extollo/lib'
import {AbstractBlog, BlogBackend, BlogPostFrontMatter, isBlogPostFrontMatter} from './AbstractBlog.service'
import {Country, isCountry} from './countries'
import * as marked from 'marked'
export type FoodBlogPostFrontMatter = BlogPostFrontMatter & {
country: Country,
}
@Singleton()
export class FoodBlog extends AbstractBlog<FoodBlogPostFrontMatter> {
private countryRenderCache: Partial<Record<Country, string>> = {}
public async getCountryBlurb(country: Country): Promise<Maybe<string>> {
if ( typeof this.countryRenderCache[country] === 'undefined' ) {
const path = appPath('resources', 'food-blog-countries', `${country}.md`)
let contents = ''
if ( await path.exists() ) {
contents = marked.marked(await path.read())
}
this.countryRenderCache[country] = contents
}
return this.countryRenderCache[country]
}
protected isValidFrontMatter(what: unknown): what is FoodBlogPostFrontMatter {
return (
isBlogPostFrontMatter(what)
&& hasOwnProperty(what, 'country')
&& isCountry(what.country)
)
}
protected getBackend(): BlogBackend<FoodBlogPostFrontMatter> {
return {
routePrefix: '/food',
resourcePath: ['food-blog'],
author: {
name: 'Garrett Mills',
link: 'https://garrettmills.dev/#about',
email: 'shout@garrettmills.dev',
},
}
}
protected createFeed(lastUpdated: Maybe<Date>): Awaitable<RSSFeed.Feed> {
const feed = new RSSFeed.Feed({
title: 'Eating the World',
description: 'Eating the World is a lifetime exploration of international food.',
id: `${this.routing.getAppUrl()}/food/about`,
link: this.routing.getNamedPath('etw').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: lastUpdated,
generator: '',
/*feedLinks: {
json: this.routing.getNamedPath('food-blog:json').toRemote,
atom: this.routing.getNamedPath('food-blog:atom').toRemote,
rss: this.routing.getNamedPath('food-blog:rss').toRemote,
},*/
author: {
name: 'Garrett Mills',
email: 'shout@garrettmills.dev',
link: 'https://garrettmills.dev/#about',
},
})
feed.addCategory('Food')
feed.addCategory('International Cuisine')
return feed
}
}

View File

@ -0,0 +1,263 @@
export const countryNames = {
AF: 'Afghanistan',
AX: 'Åland Islands',
AL: 'Albania',
DZ: 'Algeria',
AS: 'American Samoa',
AD: 'Andorra',
AO: 'Angola',
AI: 'Anguilla',
AQ: 'Antarctica',
AG: 'Antigua and Barbuda',
AR: 'Argentina',
AM: 'Armenia',
AW: 'Aruba',
AU: 'Australia',
AT: 'Austria',
AZ: 'Azerbaijan',
BS: 'Bahamas',
BH: 'Bahrain',
BD: 'Bangladesh',
BB: 'Barbados',
BY: 'Belarus',
BE: 'Belgium',
BZ: 'Belize',
BJ: 'Benin',
BM: 'Bermuda',
BT: 'Bhutan',
BO: 'Bolivia',
BA: 'Bosnia and Herzegovina',
BW: 'Botswana',
BR: 'Brazil',
IO: 'British Indian Ocean Territory',
VG: 'British Virgin Islands',
BN: 'Brunei Darussalam',
BG: 'Bulgaria',
BF: 'Burkina Faso',
BI: 'Burundi',
KH: 'Cambodia',
CM: 'Cameroon',
CA: 'Canada',
CV: 'Cape Verde',
BQ: 'Caribbean Netherlands',
KY: 'Cayman Islands',
CF: 'Central African Republic',
TD: 'Chad',
CL: 'Chile',
CN: 'China',
CX: 'Christmas Island',
CC: 'Cocos Islands',
CO: 'Colombia',
KM: 'Comoros',
CG: 'Congo',
CK: 'Cook Islands',
CR: 'Costa Rica',
HR: 'Croatia',
CU: 'Cuba',
CW: 'Curaçao',
CY: 'Cyprus',
CZ: 'Czech Republic',
CD: 'Democratic Republic of the Congo',
DK: 'Denmark',
DJ: 'Djibouti',
DM: 'Dominica',
DO: 'Dominican Republic',
EC: 'Ecuador',
EG: 'Egypt',
SV: 'El Salvador',
GQ: 'Equatorial Guinea',
ER: 'Eritrea',
EE: 'Estonia',
ET: 'Ethiopia',
FK: 'Falkland Islands',
FO: 'Faroe Islands',
FM: 'Federated States of Micronesia',
FJ: 'Fiji',
FI: 'Finland',
FR: 'France',
GF: 'French Guiana',
PF: 'French Polynesia',
TF: 'French Southern Territories',
GA: 'Gabon',
GM: 'Gambia',
GE: 'Georgia',
DE: 'Germany',
GH: 'Ghana',
GI: 'Gibraltar',
GR: 'Greece',
GL: 'Greenland',
GD: 'Grenada',
GP: 'Guadeloupe',
GU: 'Guam',
GT: 'Guatemala',
GN: 'Guinea',
GW: 'Guinea-Bissau',
GY: 'Guyana',
HT: 'Haiti',
HN: 'Honduras',
HK: 'Hong Kong',
HU: 'Hungary',
IS: 'Iceland',
IN: 'India',
ID: 'Indonesia',
IR: 'Iran',
IQ: 'Iraq',
IE: 'Ireland',
IM: 'Isle of Man',
IL: 'Israel',
IT: 'Italy',
CI: 'Ivory Coast',
JM: 'Jamaica',
JP: 'Japan',
JE: 'Jersey',
JO: 'Jordan',
KZ: 'Kazakhstan',
KE: 'Kenya',
KI: 'Kiribati',
XK: 'Kosovo',
KW: 'Kuwait',
KG: 'Kyrgyzstan',
LA: 'Laos',
LV: 'Latvia',
LB: 'Lebanon',
LS: 'Lesotho',
LR: 'Liberia',
LY: 'Libya',
LI: 'Liechtenstein',
LT: 'Lithuania',
LU: 'Luxembourg',
MO: 'Macau',
MK: 'Macedonia',
MG: 'Madagascar',
MW: 'Malawi',
MY: 'Malaysia',
MV: 'Maldives',
ML: 'Mali',
MT: 'Malta',
MH: 'Marshall Islands',
MQ: 'Martinique',
MR: 'Mauritania',
MU: 'Mauritius',
YT: 'Mayotte',
MX: 'Mexico',
MD: 'Moldova',
MC: 'Monaco',
MN: 'Mongolia',
ME: 'Montenegro',
MS: 'Montserrat',
MA: 'Morocco',
MZ: 'Mozambique',
MM: 'Myanmar',
NA: 'Namibia',
NR: 'Nauru',
NP: 'Nepal',
NL: 'Netherlands',
NC: 'New Caledonia',
NZ: 'New Zealand',
NI: 'Nicaragua',
NE: 'Niger',
NG: 'Nigeria',
NU: 'Niue',
NF: 'Norfolk Island',
KP: 'North Korea',
MP: 'Northern Mariana Islands',
NO: 'Norway',
OM: 'Oman',
PK: 'Pakistan',
PW: 'Palau',
PS: 'Palestine',
PA: 'Panama',
PG: 'Papua New Guinea',
PY: 'Paraguay',
PE: 'Peru',
PH: 'Philippines',
PN: 'Pitcairn Islands',
PL: 'Poland',
PT: 'Portugal',
PR: 'Puerto Rico',
QA: 'Qatar',
RE: 'Reunion',
RO: 'Romania',
RU: 'Russia',
RW: 'Rwanda',
SH: 'Saint Helena',
KN: 'Saint Kitts and Nevis',
LC: 'Saint Lucia',
PM: 'Saint Pierre and Miquelon',
VC: 'Saint Vincent and the Grenadines',
WS: 'Samoa',
SM: 'San Marino',
ST: 'São Tomé and Príncipe',
SA: 'Saudi Arabia',
SN: 'Senegal',
RS: 'Serbia',
SC: 'Seychelles',
SL: 'Sierra Leone',
SG: 'Singapore',
SX: 'Sint Maarten',
SK: 'Slovakia',
SI: 'Slovenia',
SB: 'Solomon Islands',
SO: 'Somalia',
ZA: 'South Africa',
GS: 'South Georgia and the South Sandwich Islands',
KR: 'South Korea',
SS: 'South Sudan',
ES: 'Spain',
LK: 'Sri Lanka',
SD: 'Sudan',
SR: 'Suriname',
SJ: 'Svalbard and Jan Mayen',
SZ: 'Eswatini',
SE: 'Sweden',
CH: 'Switzerland',
SY: 'Syria',
TW: 'Taiwan',
TJ: 'Tajikistan',
TZ: 'Tanzania',
TH: 'Thailand',
TL: 'Timor-Leste',
TG: 'Togo',
TK: 'Tokelau',
TO: 'Tonga',
TT: 'Trinidad and Tobago',
TN: 'Tunisia',
TR: 'Turkey',
TM: 'Turkmenistan',
TC: 'Turks and Caicos Islands',
TV: 'Tuvalu',
UG: 'Uganda',
UA: 'Ukraine',
AE: 'United Arab Emirates',
GB: 'United Kingdom',
US: 'United States',
UM: 'United States Minor Outlying Islands',
VI: 'United States Virgin Islands',
UY: 'Uruguay',
UZ: 'Uzbekistan',
VU: 'Vanuatu',
VA: 'Vatican City',
VE: 'Venezuela',
VN: 'Vietnam',
WF: 'Wallis and Futuna',
EH: 'Western Sahara',
YE: 'Yemen',
ZM: 'Zambia',
ZW: 'Zimbabwe'
}
export const countries = Object.keys(countryNames) as Country[]
export type Country = keyof (typeof countryNames)
export type MapPosition = { country: 'FR', zoom: number, x: number, y: number }
export const isCountry = (what: unknown): what is Country => (
typeof what === 'string'
&& countries.includes(what as any)
)
export const mapPositions: Partial<Record<Country, MapPosition>> = {
FR: { country: 'FR', zoom: 4, x: 500, y: 100 },
}

9
tsconfig.client.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "esnext",
"outDir": "lib/app/resources/assets/app",
"lib": ["dom", "es2015"]
},
"include": ["src/app/resources/assets/app"]
}

View File

@ -9,9 +9,7 @@
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"lib": ["esnext", "dom", "dom.iterable"],
"preserveSymlinks": true,
"jsx": "react",
"reactNamespace": "JSX"
"preserveSymlinks": true
},
"include": ["src"]
}

8
tsconfig.node.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "lib"
},
"exclude": ["src/client"]
}