Initial Release
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { inspect } from 'util'
|
||||
|
||||
export default function Log(
|
||||
severity: 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'DATA',
|
||||
service: string,
|
||||
message: string,
|
||||
data?: any
|
||||
) {
|
||||
const d = new Date()
|
||||
const t = `${d.toDateString().slice(4)} ${d.toTimeString().slice(0, 8)}`
|
||||
process.stdout.write(`${t} [${severity}] ${service}: ${message}\x1b[0m\n`)
|
||||
if (data) {
|
||||
process.stdout.write(inspect(data, false, Infinity, true))
|
||||
process.stdout.write('\n---\n\n')
|
||||
}
|
||||
if (severity === 'FATAL') process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { RawAttributes } from 'node-html-parser/dist/nodes/html'
|
||||
import { Element, TableElement } from '../types/articles'
|
||||
import { parse } from 'node-html-parser'
|
||||
import hljs from "highlight.js"
|
||||
import { inspect } from 'util'
|
||||
|
||||
/** Parses and extracts elements from an Article */
|
||||
export default function parseArticle(someDocument: string): [RawAttributes, Array<Element>] {
|
||||
const clean = (s: string) => s.split('\n').map(s => s.trim()).join(' ').trim()
|
||||
const root = parse(someDocument, {}).querySelector('article')
|
||||
const data = new Array<Element>()
|
||||
if (!root || root.tagName !== 'ARTICLE') {
|
||||
throw 'Expected Article Tag'
|
||||
}
|
||||
for (const tag of root.childNodes) {
|
||||
const tagName = tag.rawTagName.trim().toLowerCase()
|
||||
// @ts-expect-error
|
||||
const element = tag as HTMLElement
|
||||
|
||||
switch (tagName) {
|
||||
|
||||
case 'quote':
|
||||
case 'header':
|
||||
case 'subheader':
|
||||
data.push({
|
||||
'type': tagName,
|
||||
'value': tag.textContent
|
||||
})
|
||||
break
|
||||
|
||||
case 'text':
|
||||
data.push({
|
||||
'type': 'text',
|
||||
'items': (
|
||||
tag.childNodes.map(child => {
|
||||
const data: Record<string, string> = {}
|
||||
if (child.rawTagName === 'a') {
|
||||
// @ts-expect-error
|
||||
const c = child as HTMLElement
|
||||
data.href = c.getAttribute('href')!
|
||||
data.target = c.getAttribute('target')!
|
||||
}
|
||||
return Object.assign(data, {
|
||||
'tag': child.rawTagName,
|
||||
'content': clean(child.textContent),
|
||||
})
|
||||
})
|
||||
.filter(c => c.content.length !== 0)
|
||||
)
|
||||
})
|
||||
break
|
||||
|
||||
case 'code':
|
||||
const code = tag.textContent.trim()
|
||||
const syntax = element.getAttribute('syntax')!
|
||||
data.push({
|
||||
'type': 'code',
|
||||
'content': (
|
||||
syntax !== 'skip'
|
||||
? hljs.highlight(code, { 'language': syntax }).value
|
||||
: code
|
||||
)
|
||||
})
|
||||
break
|
||||
|
||||
case 'list':
|
||||
data.push({
|
||||
'type': 'list',
|
||||
'items': (
|
||||
tag.childNodes
|
||||
.map(c => clean(c.textContent))
|
||||
.filter(e => e.length !== 0)
|
||||
)
|
||||
})
|
||||
break
|
||||
|
||||
case 'table':
|
||||
const table: TableElement = {
|
||||
type: 'table',
|
||||
items: []
|
||||
}
|
||||
for (const row of tag.childNodes) {
|
||||
const items = new Array<string>()
|
||||
if (row.rawTagName !== 'row') continue
|
||||
for (const column of row.childNodes) {
|
||||
if (column.rawTagName !== 'column') continue
|
||||
items.push(clean(column.textContent))
|
||||
}
|
||||
table.items.push(items)
|
||||
}
|
||||
data.push(table)
|
||||
break
|
||||
|
||||
case 'image':
|
||||
case 'banner':
|
||||
case 'beanie':
|
||||
data.push({
|
||||
'type': tagName,
|
||||
'resource': element.getAttribute('resource')!,
|
||||
'caption': element.getAttribute('caption')!,
|
||||
'alt': element.getAttribute('alt')!,
|
||||
})
|
||||
break
|
||||
|
||||
case 'video':
|
||||
data.push({
|
||||
'type': tagName,
|
||||
'resource': element.getAttribute('resource')!,
|
||||
'caption': element.getAttribute('caption')!,
|
||||
'alt': element.getAttribute('alt')!,
|
||||
'cover': element.getAttribute('cover')!,
|
||||
})
|
||||
break
|
||||
|
||||
case 'audio':
|
||||
data.push({
|
||||
'type': tagName,
|
||||
'resource': element.getAttribute('resource')!,
|
||||
'name': element.getAttribute('name')!,
|
||||
})
|
||||
break
|
||||
|
||||
case '': break
|
||||
default:
|
||||
throw `Unsupported Tag Name '${tagName}'`
|
||||
}
|
||||
}
|
||||
return [
|
||||
root.rawAttributes,
|
||||
data
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { CONTENT_TYPE_MAP, PROCESSOR_ASSETS, PROCESSOR_DIRECT, PROCESSOR_INDEX, PROCESSOR_PREFIX, PROCESSOR_PRESETS, PRODUCTION } from '../constants'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { extname, join } from 'path'
|
||||
import { createHash } from 'crypto'
|
||||
import cleanCss from 'clean-css'
|
||||
import * as js from 'uglify-js'
|
||||
import * as ts from 'typescript'
|
||||
import sharp from 'sharp'
|
||||
import Log from './logger'
|
||||
import { byteSize } from './pugHelpers'
|
||||
|
||||
const PROCCESED = new Map()
|
||||
|
||||
// Background processes a file making it available at the returned path
|
||||
export function processFile(uri: string, dry = false) {
|
||||
return new Promise<string>(async (ok, cancel) => {
|
||||
if (!uri.startsWith(PROCESSOR_PREFIX)) {
|
||||
cancel(`URI '${uri}' must begin with ${PROCESSOR_PREFIX}`)
|
||||
return
|
||||
}
|
||||
if (dry) {
|
||||
ok(`/asset-processor/${uri.slice(PROCESSOR_PREFIX.length)}`)
|
||||
return
|
||||
}
|
||||
if (PRODUCTION && PROCCESED.has(uri)) {
|
||||
return ok(PROCCESED.get(uri))
|
||||
}
|
||||
|
||||
// Read Contents From Disk
|
||||
const t = Date.now()
|
||||
const [pathname, search] = uri.slice(PROCESSOR_PREFIX.length).split('?', 2)
|
||||
const params = new URLSearchParams(search)
|
||||
const path = join(process.cwd(), 'assets', pathname)
|
||||
if (!existsSync(path)) {
|
||||
cancel(`Path '${path}' does not exist`)
|
||||
return
|
||||
}
|
||||
const input = readFileSync(path)
|
||||
const extension = extname(pathname).slice(1)
|
||||
|
||||
function complete(ext: string, output: string | Buffer) {
|
||||
const outHash = createHash('md5').update(output).digest('hex').slice(0, 8)
|
||||
const uriPath =
|
||||
(params.get('direct') ? PROCESSOR_DIRECT : '') +
|
||||
`/assets/${pathname.slice(0, pathname.length - extension.length)}${ext}`
|
||||
const uriFull = `${uriPath}?v=${outHash}`
|
||||
const index = PROCESSOR_ASSETS.push({
|
||||
'type': CONTENT_TYPE_MAP[ext] || 'application/octet-stream',
|
||||
'size': Buffer.byteLength(output),
|
||||
'file': output,
|
||||
}) - 1
|
||||
PROCESSOR_INDEX[uriPath] = index
|
||||
PROCESSOR_INDEX[uriFull] = index
|
||||
PROCCESED.set(uri, uriFull)
|
||||
Log('DEBUG', 'processFile', `'${uriFull}' (${byteSize(output.length)}) (${Date.now() - t}ms)`)
|
||||
ok(uriFull)
|
||||
}
|
||||
switch (extension) {
|
||||
|
||||
// Resize Image
|
||||
case 'webp':
|
||||
case 'jpeg':
|
||||
case 'jpg':
|
||||
case 'png':
|
||||
case 'gif': {
|
||||
const preset = PROCESSOR_PRESETS[params.get('preset') || '']
|
||||
const animated = params.get('animated') !== null
|
||||
complete(
|
||||
preset ? preset.format : extension,
|
||||
preset ? await sharp(input, { animated })
|
||||
.resize(preset.width, preset.height, preset.resize)
|
||||
.toFormat(preset.format, { 'quality': preset.quality })
|
||||
.toBuffer()
|
||||
: input
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// Minify Script
|
||||
case 'js': {
|
||||
const minified = js.minify(input.toString(), { mangle: true })
|
||||
if (minified.error) {
|
||||
cancel(`JavaScript Minify Error: ${minified.error}`)
|
||||
return
|
||||
}
|
||||
complete('js', minified.code)
|
||||
break
|
||||
}
|
||||
|
||||
// Transpile and Minify Script
|
||||
case 'ts': {
|
||||
const script = ts.transpile(input.toString(), {
|
||||
'target': ts.ScriptTarget.ES2022,
|
||||
'module': ts.ModuleKind.CommonJS,
|
||||
'lib': ['DOM', 'ES2022'],
|
||||
'allowSyntheticDefaultImports': true,
|
||||
'skipLibCheck': true,
|
||||
'strict': true,
|
||||
})
|
||||
const minified = js.minify(script, { mangle: true })
|
||||
if (minified.error) {
|
||||
cancel(`JavaScript Minify Error: ${minified.error}`)
|
||||
return
|
||||
}
|
||||
complete('js', minified.code)
|
||||
break
|
||||
}
|
||||
|
||||
// Minify Styles
|
||||
case 'css': {
|
||||
const css = input.toString()
|
||||
const search = /('file:\/\/[^']+')/g
|
||||
const promises = new Array<ReturnType<typeof processFile>>()
|
||||
|
||||
// Search for URIs inside CSS File
|
||||
css.replaceAll(search, uri => {
|
||||
promises.push(processFile(uri.slice(1, -1), !PRODUCTION))
|
||||
return uri
|
||||
})
|
||||
Promise.all(promises).then(results => {
|
||||
// Replace all URIs and Minify
|
||||
let index = 0
|
||||
const minifed = new cleanCss().minify(
|
||||
css.replaceAll(search, () => results[index++])
|
||||
)
|
||||
if (minifed.errors.length) {
|
||||
cancel(`Minify CSS Error: ${minifed.errors.join(', ')}`)
|
||||
return
|
||||
}
|
||||
complete('css', minifed.styles)
|
||||
|
||||
}).catch(e => {
|
||||
cancel(`CSS Processing Errors: ${e}`)
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Unsupported, Skip Processing!
|
||||
default:
|
||||
complete(extension, input)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { byteSize, calcReadTime, formatDate, formatHref } from "./pugHelpers"
|
||||
import { PROCESSOR_PREFIX, PRODUCTION } from "../constants"
|
||||
import { processFile } from "./processFile"
|
||||
import parse from "node-html-parser"
|
||||
import pug from 'pug'
|
||||
|
||||
const replaceAttr = new Array<string>('src', 'href', 'content', 'poster')
|
||||
const replaceQuery = new Array<string>(
|
||||
'img[src]', // Image Elements
|
||||
'source[src]', // Video Sources
|
||||
'audio[src]', // Audio Sources
|
||||
'script[src]', // Script Elements
|
||||
'link[href]', // CSS Links
|
||||
'meta[content]', // Meta OG Tags
|
||||
'a[href]', // Download Anchors
|
||||
'video[poster]', // Video Posters
|
||||
).join(',')
|
||||
|
||||
// Render a View and Process all Files
|
||||
export default async function processView(path: string, locals: any): Promise<string> {
|
||||
const document = parse(
|
||||
pug.renderFile(path, Object.assign(locals, {
|
||||
'pretty': !PRODUCTION,
|
||||
'formatBytes': byteSize,
|
||||
'formatDate': formatDate,
|
||||
'formatString': formatHref,
|
||||
'calcReadTime': calcReadTime,
|
||||
})),
|
||||
{ comment: true }
|
||||
)
|
||||
|
||||
// Process Inline Styles
|
||||
for await (const elem of document.querySelectorAll('*[style]')) {
|
||||
let css = elem.getAttribute('style')
|
||||
if (!css) continue
|
||||
const search = /('file:\/\/[^']+')/g
|
||||
const promises = new Array<ReturnType<typeof processFile>>()
|
||||
css.replaceAll(search, uri => {
|
||||
promises.push(processFile(uri.slice(1, -1), !PRODUCTION))
|
||||
return uri
|
||||
})
|
||||
await Promise.all(promises).then(results => {
|
||||
let index = 0
|
||||
css = css!.replaceAll(search, () => results[index++])
|
||||
})
|
||||
elem.setAttribute('style', css)
|
||||
}
|
||||
|
||||
// Process Element Sources
|
||||
for (const elem of document.querySelectorAll(replaceQuery)) {
|
||||
const attrKey = replaceAttr.find(e => elem.getAttribute(e))
|
||||
if (!attrKey) continue
|
||||
const attrVal = elem.getAttribute(attrKey)
|
||||
if (attrVal && attrVal.startsWith(PROCESSOR_PREFIX)) {
|
||||
elem.setAttribute(attrKey, await processFile(attrVal, !PRODUCTION))
|
||||
}
|
||||
}
|
||||
|
||||
return document.toString()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Element } from "../types/articles"
|
||||
|
||||
// Format ISO Timestamp into Generic Date (ex: Jun 26th 2003)
|
||||
export function formatDate(isoTimestamp: string): string {
|
||||
const t = new Date(isoTimestamp)
|
||||
const m = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dece'][t.getMonth()]
|
||||
const d = t.getDate()
|
||||
const y = t.getFullYear()
|
||||
return `${m} ${d}${d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th'} ${y}`
|
||||
}
|
||||
|
||||
// Format String for Usage in HREF Tags
|
||||
export function formatHref(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^A-Za-z0-9 ]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
}
|
||||
|
||||
// Determine byte size by length
|
||||
export function byteSize(length: number) {
|
||||
if (length > 1e+9) return `${(length / 1e+9).toFixed(2)}gb`
|
||||
if (length > 1e+6) return `${(length / 1e+6).toFixed(2)}mb`
|
||||
if (length > 1024) return `${(length / 1024).toFixed(2)}kb`
|
||||
return `${length}b`
|
||||
}
|
||||
|
||||
// Estimate Required Time to Read this Article
|
||||
export function calcReadTime(articleElements: Array<Element>): number {
|
||||
return Math.ceil(
|
||||
articleElements
|
||||
.map((e): number => {
|
||||
const AVERAGE_READING_SPEED = 200 // wpm
|
||||
switch (e.type) {
|
||||
|
||||
// Calculate seconds based on average reading speed divided by amount of characters
|
||||
case 'list': return e.items.map(e => e.length).reduce((a, b) => a + b, 0) / AVERAGE_READING_SPEED
|
||||
case 'text': return e.items.map(e => e.content.split(' ').length).reduce((a, b) => a + b, 0) / AVERAGE_READING_SPEED
|
||||
case 'code': return e.content.split(' ').length / AVERAGE_READING_SPEED
|
||||
|
||||
case 'subheader':
|
||||
case 'header':
|
||||
case 'quote':
|
||||
return e.value.split(' ').length / AVERAGE_READING_SPEED
|
||||
case 'table':
|
||||
return e.items
|
||||
.map(r => r.map(c => c.split(' ').length).reduce((a, b) => a + b, 0))
|
||||
.reduce((a, b) => a + b, 0)
|
||||
/ AVERAGE_READING_SPEED
|
||||
|
||||
// Static increases for each image element
|
||||
case 'image':
|
||||
case 'banner':
|
||||
case 'beanie':
|
||||
return 0.25 // 15 seconds
|
||||
|
||||
// Static increases for each video element
|
||||
case 'video':
|
||||
case 'audio':
|
||||
return 0.50 // 30 seconds
|
||||
}
|
||||
})
|
||||
.reduce((a, b) => a + b, 0)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Request, RequestHandler, Response } from 'express'
|
||||
import { PRODUCTION } from '../constants'
|
||||
import { createHash } from 'crypto'
|
||||
import processView from './processView'
|
||||
import Log from './logger'
|
||||
|
||||
// Renders a View from Disk and Serves it with Caching Enabled
|
||||
export async function Static(path: string): Promise<RequestHandler> {
|
||||
if (PRODUCTION) {
|
||||
Log('INFO', 'static', `Pre-Render: '${path}'`)
|
||||
const content = await processView(path, {})
|
||||
const length = Buffer.byteLength(content)
|
||||
const hash = createHash('md5').update(content).digest('hex')
|
||||
|
||||
return function (req: Request, res: Response) {
|
||||
if (req.headers['if-none-match'] === hash) {
|
||||
res.status(304).end()
|
||||
return
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.setHeader('Content-Length', length)
|
||||
res.setHeader('ETag', hash)
|
||||
res.end(content)
|
||||
}
|
||||
} else {
|
||||
return async function (req: Request, res: Response) {
|
||||
Log('INFO', 'static', `Render: '${path}'`)
|
||||
processView(path, {})
|
||||
.then(html => res.send(html))
|
||||
.catch(err => res
|
||||
.status(500)
|
||||
.end('Caught Rendering Error! Check Console for more details.\n' + String(err))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Redirect to Another Location
|
||||
export function Redirect(location: string) {
|
||||
return function (req: Request, res: Response) {
|
||||
res.redirect(location)
|
||||
}
|
||||
}
|
||||
|
||||
// Return a Response Code with an Empty Body
|
||||
export function Empty(status: number) {
|
||||
return function (req: Request, res: Response) {
|
||||
res.status(status).end()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user