Build ETW blog + update cobalt
This commit is contained in:
parent
adac17825b
commit
4686bcc1b8
@ -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",
|
||||
|
569
pnpm-lock.yaml
569
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -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[],
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
153
src/app/http/controllers/FoodBlog.controller.ts
Normal file
153
src/app/http/controllers/FoodBlog.controller.ts
Normal 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}`
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
29
src/app/models/util/PendingTransaction.model.ts
Normal file
29
src/app/models/util/PendingTransaction.model.ts
Normal 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
|
||||
}
|
61
src/app/resources/assets/app/App.ts
Normal file
61
src/app/resources/assets/app/App.ts
Normal 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]
|
||||
}
|
||||
}
|
212
src/app/resources/assets/app/BehaviorSubject.ts
Normal file
212
src/app/resources/assets/app/BehaviorSubject.ts
Normal 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
|
||||
}
|
||||
}
|
14
src/app/resources/assets/app/Component.ts
Normal file
14
src/app/resources/assets/app/Component.ts
Normal 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 {}
|
||||
}
|
31
src/app/resources/assets/app/components/MessageContainer.ts
Normal file
31
src/app/resources/assets/app/components/MessageContainer.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
5
src/app/resources/assets/app/components/index.ts
Normal file
5
src/app/resources/assets/app/components/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import {MessageContainer} from './MessageContainer'
|
||||
|
||||
export const registerComponents = () => {
|
||||
customElements.define('ex-messages', MessageContainer)
|
||||
}
|
4
src/app/resources/assets/app/index.ts
Normal file
4
src/app/resources/assets/app/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
export * from './App'
|
||||
export * from './types'
|
||||
export * from './util'
|
28
src/app/resources/assets/app/types.ts
Normal file
28
src/app/resources/assets/app/types.ts
Normal 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
|
||||
}
|
52
src/app/resources/assets/app/util.ts
Normal file
52
src/app/resources/assets/app/util.ts
Normal 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> {
|
||||
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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);
|
||||
|
3
src/app/resources/assets/map/svg-pan-zoom.min.js
vendored
Normal file
3
src/app/resources/assets/map/svg-pan-zoom.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
src/app/resources/assets/map/svgMap.min.css
vendored
Normal file
2
src/app/resources/assets/map/svgMap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
src/app/resources/assets/map/svgMap.min.js
vendored
Normal file
2
src/app/resources/assets/map/svgMap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/app/resources/food-blog-countries/FR.md
Normal file
1
src/app/resources/food-blog-countries/FR.md
Normal 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 Provence’s bouillabaisse and Lorraine’s 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.
|
0
src/app/resources/food-blog/.gitkeep
Normal file
0
src/app/resources/food-blog/.gitkeep
Normal 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
|
||||
|
24
src/app/resources/views/food/about.pug
Normal file
24
src/app/resources/views/food/about.pug
Normal 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
|
13
src/app/resources/views/food/archive.pug
Normal file
13
src/app/resources/views/food/archive.pug
Normal 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}
|
13
src/app/resources/views/food/country.pug
Normal file
13
src/app/resources/views/food/country.pug
Normal 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}
|
6
src/app/resources/views/food/feeds.pug
Normal file
6
src/app/resources/views/food/feeds.pug
Normal 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>.
|
108
src/app/resources/views/food/page.pug
Normal file
108
src/app/resources/views/food/page.pug
Normal 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}
|
||||
},
|
||||
},
|
||||
})
|
31
src/app/resources/views/food/post.pug
Normal file
31
src/app/resources/views/food/post.pug
Normal 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> | by Garrett Mills on #{blogDate(post.date)}
|
||||
|
||||
.post-content !{renderedPost}
|
||||
|
19
src/app/resources/views/food/welcome.pug
Normal file
19
src/app/resources/views/food/welcome.pug
Normal 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
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
152
src/app/services/blog/AbstractBlog.service.ts
Normal file
152
src/app/services/blog/AbstractBlog.service.ts
Normal 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}/`
|
||||
}
|
||||
}
|
79
src/app/services/blog/FoodBlog.service.ts
Normal file
79
src/app/services/blog/FoodBlog.service.ts
Normal 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
|
||||
}
|
||||
}
|
263
src/app/services/blog/countries.ts
Normal file
263
src/app/services/blog/countries.ts
Normal 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
9
tsconfig.client.json
Normal 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"]
|
||||
}
|
@ -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
8
tsconfig.node.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"exclude": ["src/client"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user