Initial Release
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
import { Locals, Manifest, ManifestArticle, ManifestEntry } from '../types/articles'
|
||||
import { PRODUCTION } from '../constants'
|
||||
import parseArticle from '../functions/parseArticle'
|
||||
import processView from '../functions/processView'
|
||||
import Log from '../functions/logger'
|
||||
|
||||
import { existsSync, readFileSync, watch } from 'fs'
|
||||
import { Request, Response } from 'express'
|
||||
import { createHash } from 'crypto'
|
||||
import { join } from 'path'
|
||||
|
||||
type ArticleID = number
|
||||
type ElementID = number
|
||||
type WordID = number
|
||||
const stripSpaces = (t: string) => t.replaceAll(/[\s]{2,}/gm, ' ').trim()
|
||||
const stripSpecial = (t: string) => t.replaceAll(/[^a-z0-9_-\s]/gm, ' ')
|
||||
const difference = (a: string, b: string): number => {
|
||||
let match = b.length
|
||||
if (Math.abs(a.length - b.length) < 2) {
|
||||
for (const i in b.split('')) {
|
||||
if (a[i] === b[i]) match--
|
||||
}
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
export class Articles {
|
||||
private path: string
|
||||
private directory: string
|
||||
private articles = new Array<ManifestArticle>()
|
||||
private categories = new Array<ManifestEntry>()
|
||||
private authors = new Array<ManifestEntry>()
|
||||
private indexWord: Record<string, Array<[ArticleID, ElementID, WordID]>> = {}
|
||||
private indexData = new Array<Array<Array<string>>>(
|
||||
// Article > Elements > Words
|
||||
)
|
||||
private views: Record<string, {
|
||||
hash: string; // Document Hash for Caching
|
||||
size: number; // Document Size
|
||||
content: string; // Document Content
|
||||
}> = {}
|
||||
|
||||
constructor(folder: string, path: string) {
|
||||
this.directory = join('views', folder)
|
||||
this.path = path
|
||||
if (!PRODUCTION) {
|
||||
Log('DEBUG', 'Articles', `${this.directory}: Watching Directory`)
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
let callback = () => {
|
||||
Log('INFO', 'Articles', `${this.directory}: Changes Detected, Reloading...`)
|
||||
this.reload().catch(e => {
|
||||
Log('ERROR', 'Articles', `${this.directory}: Reload Error Caught`, e)
|
||||
})
|
||||
}
|
||||
watch(this.directory, { recursive: true }, () => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => callback(), 10)
|
||||
})
|
||||
}
|
||||
this.reload()
|
||||
}
|
||||
|
||||
private async reload() {
|
||||
this.authors = []
|
||||
this.articles = []
|
||||
this.categories = []
|
||||
this.indexData = []
|
||||
this.indexWord = {}
|
||||
|
||||
// Validate Folder Structure
|
||||
if (!existsSync(join(this.directory, 'browser.pug')))
|
||||
throw `${this.directory}: File 'browser.pug' is required`
|
||||
if (!existsSync(join(this.directory, 'post.pug')))
|
||||
throw `${this.directory}: File 'post.pug' is required`
|
||||
if (!existsSync(join(this.directory, 'manifest.json')))
|
||||
throw `${this.directory}: File 'manifest.json' is required`
|
||||
if (!existsSync(join(this.directory, 'articles')))
|
||||
throw `${this.directory}: Folder 'articles' is required`
|
||||
|
||||
// Read and Parse Manifest
|
||||
let manifest!: Manifest
|
||||
try {
|
||||
const data = readFileSync(join(this.directory, 'manifest.json'), 'utf8')
|
||||
manifest = JSON.parse(data)
|
||||
|
||||
if (!Array.isArray(manifest.categories))
|
||||
throw `Field 'categories' must be an array`
|
||||
if (!Array.isArray(manifest.authors))
|
||||
throw `Field 'authors' must be an array`
|
||||
if (!Array.isArray(manifest.articles))
|
||||
throw `Field 'articles' must be an array`
|
||||
if (manifest.itemsPerPage !== undefined && typeof manifest.itemsPerPage !== 'number')
|
||||
throw `Field 'itemsPerPage' must be a number`
|
||||
if (manifest.noBrowser !== undefined && typeof manifest.noBrowser !== 'boolean')
|
||||
throw `Field 'noBrowser' must be a boolean`
|
||||
|
||||
let i = 0
|
||||
for (const item of manifest['articles']) {
|
||||
if (typeof item !== 'string')
|
||||
throw `${i}: Must of 'string' type`
|
||||
i++
|
||||
}
|
||||
i = 0
|
||||
|
||||
for (const field of [manifest['categories'], manifest['authors']]) {
|
||||
let m: Record<string, number> = {}
|
||||
for (const item of field) {
|
||||
if (item.id === undefined)
|
||||
throw `${i}: Missing field 'id'`
|
||||
if (item.icon === undefined)
|
||||
throw `${i}: Missing field 'icon'`
|
||||
if (item.name === undefined)
|
||||
throw `${i}: Missing field 'name'`
|
||||
if (m[item.id])
|
||||
throw `${i}: Duplicate ID with index ${m[item.id]}`
|
||||
i++
|
||||
}
|
||||
}
|
||||
this.categories = manifest['categories']
|
||||
this.authors = manifest['authors']
|
||||
} catch (e) {
|
||||
throw `${this.directory}: Unable to Parse Manifest (${e})`
|
||||
}
|
||||
|
||||
// Read and Parse Articles
|
||||
for (const filepath of manifest.articles) {
|
||||
try {
|
||||
const path = join(this.directory, 'articles', filepath)
|
||||
const file = readFileSync(path, 'utf8')
|
||||
const [attributes, elements] = parseArticle(file)
|
||||
for (const field of [
|
||||
'id', 'created', 'categoryId', 'authorId',
|
||||
'title', 'snippet', 'banner', 'noindex'
|
||||
]) {
|
||||
if (attributes[field] === undefined)
|
||||
throw `Attribute '${field}' must be initialized`
|
||||
}
|
||||
const author = this.authors.find(c => c.id === attributes.authorId)
|
||||
if (!author) {
|
||||
throw `Author ID is invalid`
|
||||
}
|
||||
const category = this.categories.find(c => c.id === attributes.categoryId)
|
||||
if (!category) {
|
||||
throw `Category ID is invalid`
|
||||
}
|
||||
this.articles.push({
|
||||
'info': {
|
||||
'id': attributes.id,
|
||||
'created': attributes.created,
|
||||
'categoryId': attributes.categoryId,
|
||||
'authorId': attributes.authorId,
|
||||
'title': attributes.title,
|
||||
'snippet': attributes.snippet,
|
||||
'banner': attributes.banner,
|
||||
'noindex': attributes.noindex,
|
||||
},
|
||||
'author': author,
|
||||
'category': category,
|
||||
'elements': elements,
|
||||
'path': path,
|
||||
})
|
||||
} catch (e) {
|
||||
throw `${this.directory}: Unable to Parse Article '${filepath}' (${e})`
|
||||
}
|
||||
}
|
||||
this.articles.sort((a, b) =>
|
||||
new Date(b.info.created).getTime() -
|
||||
new Date(a.info.created).getTime()
|
||||
)
|
||||
|
||||
// Generate Article Indexes
|
||||
this.articles.forEach((article, articleIndex) => {
|
||||
if (article.info.noindex === 'true') return
|
||||
|
||||
const textElements = article.elements.map(e => {
|
||||
switch (e.type) {
|
||||
case 'header':
|
||||
case 'subheader': return e.value
|
||||
case 'text': return e.items.map(e => e.content).join(' ')
|
||||
case 'list': return e.items.join(' , ')
|
||||
case 'quote': return e.value
|
||||
case 'audio': return `[AUDIO]`
|
||||
case 'image': return `[IMAGE]`
|
||||
case 'banner': return `[BANNER]`
|
||||
case 'beanie': return `[BEANIE]`
|
||||
case 'video': return `[VIDEO]`
|
||||
case 'code': return `[CODE]`
|
||||
case 'table': return `[TABLE]`
|
||||
}
|
||||
})
|
||||
|
||||
this.indexData.push(
|
||||
textElements.map((element, elementIndex) => {
|
||||
const words = stripSpaces(element).split(' ')
|
||||
words.forEach((word, wordIndex) => {
|
||||
word = word.toLowerCase()
|
||||
|
||||
// Add Entire Word to Index
|
||||
if (!this.indexWord[word]) this.indexWord[word] = []
|
||||
this.indexWord[word].push([
|
||||
articleIndex,
|
||||
elementIndex,
|
||||
wordIndex,
|
||||
])
|
||||
console.log(word)
|
||||
|
||||
// Remove Special Characters and Split Word
|
||||
const subwords = stripSpaces(stripSpecial(word)).split(' ')
|
||||
console.log(subwords)
|
||||
subwords.forEach(subword => {
|
||||
subword = subword.toLowerCase()
|
||||
if (!this.indexWord[subword]) this.indexWord[subword] = []
|
||||
this.indexWord[subword].push([
|
||||
articleIndex,
|
||||
elementIndex,
|
||||
wordIndex,
|
||||
])
|
||||
})
|
||||
|
||||
})
|
||||
return words
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
{
|
||||
const T = performance.now()
|
||||
const searchQuery = 'Get Widgets'
|
||||
const searchWords = stripSpaces(searchQuery.trim().toLowerCase()).split(' ', 16)
|
||||
|
||||
// Find Starting Words
|
||||
const sapling = new Array<string>()
|
||||
for (const word of Object.keys(this.indexWord)) {
|
||||
if (difference(word, searchWords[0]) < 2) {
|
||||
sapling.push(word)
|
||||
}
|
||||
}
|
||||
// Traverse Words
|
||||
console.log(sapling)
|
||||
for (const index of sapling) {
|
||||
for (const branch of this.indexWord[index]) {
|
||||
|
||||
console.log(index, branch)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('processing time:', performance.now() - T)
|
||||
}
|
||||
|
||||
// Pre-Render all Browser Pages
|
||||
if (!manifest.itemsPerPage) manifest.itemsPerPage = 8
|
||||
if (!manifest.noBrowser) {
|
||||
|
||||
// Add undefined so we can generate an 'all' category
|
||||
for (const index in [undefined, ...this.categories]) {
|
||||
|
||||
const category = this.categories[index]
|
||||
const relevantArticles = this.articles.filter(a => !category || a.category.id === category.id)
|
||||
const pageTotal = Math.ceil(relevantArticles.length / manifest.itemsPerPage) || 1
|
||||
|
||||
for (let pageIndex = 1; pageIndex < pageTotal + 1; pageIndex++) {
|
||||
try {
|
||||
const l: Locals = {
|
||||
'articles': this.articles,
|
||||
'relevantArticle': undefined,
|
||||
'relevantArticles': relevantArticles,
|
||||
'relevantCategory': category,
|
||||
'authors': this.authors,
|
||||
'categories': this.categories,
|
||||
'pageCurrent': pageIndex,
|
||||
'pageOffset': (pageIndex - 1) * manifest.itemsPerPage,
|
||||
'pageIndex': pageIndex,
|
||||
'pageTotal': pageTotal,
|
||||
}
|
||||
l.relevantArticles = l.relevantArticles.slice(
|
||||
l.pageOffset,
|
||||
l.pageOffset + manifest.itemsPerPage
|
||||
)
|
||||
|
||||
const document = await processView(join(this.directory, 'browser.pug'), l)
|
||||
const view = {
|
||||
'content': document,
|
||||
'size': Buffer.byteLength(document),
|
||||
'hash': createHash('md5').update(document).digest('hex'),
|
||||
}
|
||||
const path = (category ? `${this.path}/${category.id}` : `${this.path}`)
|
||||
if (pageIndex === 1) this.views[path] = view
|
||||
this.views[`${path}/${pageIndex}`] = view
|
||||
|
||||
} catch (e) {
|
||||
throw `Render Error: '${category?.id || 'all'}/${pageIndex}' (${e})`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-Render all Article Pages
|
||||
for (const article of this.articles) {
|
||||
try {
|
||||
const l: Locals = {
|
||||
'articles': this.articles,
|
||||
'relevantArticle': article,
|
||||
'relevantArticles': [],
|
||||
'relevantCategory': article.category,
|
||||
'authors': manifest.authors,
|
||||
'categories': manifest.categories,
|
||||
'pageIndex': -1,
|
||||
'pageOffset': -1,
|
||||
'pageTotal': -1,
|
||||
'pageCurrent': -1,
|
||||
}
|
||||
const document = await processView(join(this.directory, 'post.pug'), l)
|
||||
this.views[`${this.path}/${article.category.id}/${article.info.id}`] = {
|
||||
'content': document,
|
||||
'size': Buffer.byteLength(document),
|
||||
'hash': createHash('md5').update(document).digest('hex'),
|
||||
}
|
||||
} catch (e) {
|
||||
throw `Render Error: '${article.category.id}/${article.info.id}' (${e})`
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Serve a Browser Request with path (Paths: '/my-articles', '/my-articles/:cat', '/my-articles/:cat/:opt')
|
||||
public serveContent = this._serveContent.bind(this)
|
||||
private _serveContent(req: Request, res: Response) {
|
||||
let key = this.path
|
||||
if (req.params['cat']) key += `/${req.params['cat']}`
|
||||
if (req.params['opt']) key += `/${req.params['opt']}`
|
||||
const view = this.views[key]
|
||||
if (view) {
|
||||
if (req.headers['if-none-match'] === view.hash) {
|
||||
res.status(304).end()
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.setHeader('Content-Length', view.size)
|
||||
res.setHeader('Etag', view.hash)
|
||||
res.end(view.content)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Continue to 404 Handler
|
||||
if (req.next) req.next()
|
||||
}
|
||||
|
||||
// Search for Snippet of text in all article (Path: '/my-articles/snippet')
|
||||
public serveSnippet = this._serveSnippet.bind(this)
|
||||
private _serveSnippet(req: Request, res: Response) {
|
||||
res.end('todo')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// // Parse Search Query
|
||||
// // const Query = (key: string): string => (typeof req.query[key] === 'string' ? req.query[key] : '')
|
||||
// const search = Query('query') || Query('q')
|
||||
// if (!search) {
|
||||
// res.json([])
|
||||
// return
|
||||
// }
|
||||
|
||||
// // Find First Word
|
||||
// const query = this.formatSnippet(search, 12)
|
||||
// if (!query.length) {
|
||||
// res.json([])
|
||||
// return
|
||||
// }
|
||||
// const matches = this.indexWords[query[0]]
|
||||
// if (!matches) {
|
||||
// res.json([])
|
||||
// return
|
||||
// }
|
||||
|
||||
// // Generate Results
|
||||
// const results = new Array<{
|
||||
// score: number
|
||||
// link: string
|
||||
// snippet: string
|
||||
// location: string
|
||||
// }>()
|
||||
// for (const { wordIndex: wi, articleIndex: ai, elementIndex: ei } of matches) {
|
||||
// const a = this.articles[ai]
|
||||
// const e = a.elements[ei] as HeaderElement | SubheaderElement
|
||||
// let score = 0, o = 0
|
||||
// let matchWords = new Array<string>()
|
||||
// for (const queryWord of query) {
|
||||
// const articleWord = a.words[wi + o]
|
||||
// if (!articleWord) break
|
||||
// if (queryWord === this.formatWord(articleWord)) {
|
||||
// score++
|
||||
// matchWords.push(articleWord)
|
||||
// } else {
|
||||
// break
|
||||
// }
|
||||
// o++
|
||||
// }
|
||||
// if (query.length === 1 || score > 1) {
|
||||
// const location = [a.category?.name, a.info.title, e.type === 'header' ? '' : '...', e.value].filter(e => e).join(' > ')
|
||||
// const link = `${this.path}/${a.info.categoryId}/${a.info.id}#${formatHref(e.value)}`
|
||||
// const ql = matchWords.length
|
||||
// const snippet = [
|
||||
// ...a.words.slice(wi - 3, wi), // Last 3 Words
|
||||
// `<span>${matchWords.join(' ')}</span>`, // Search Query
|
||||
// ...a.words.slice(ql + wi, ql + wi + 5) // Next 5 Words
|
||||
// ].join(' ')
|
||||
// results.push({ score, link, snippet, location })
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Sort Results
|
||||
// results.sort((a, b) => b.score - a.score)
|
||||
// res.json(results)
|
||||
@@ -0,0 +1,107 @@
|
||||
import sharp from "sharp"
|
||||
try { require('dotenv').config() } catch (e) { }
|
||||
|
||||
// Export Variables
|
||||
const PARSED_ADDR = /^([A-z0-9.]+):([0-9]+)$/.exec(process.env['WEB_ADDR'] || 'localhost:8080')
|
||||
if (PARSED_ADDR === null) {
|
||||
process.stdout.write(`[ENV] Malformed TCP Address for Variable 'WEB_ADDR' (Ex. localhost:8080, 127.0.0.1:8080)\n`)
|
||||
process.exit(4)
|
||||
}
|
||||
export const PRODUCTION = (process.env['NODE_ENV']?.trim() == 'production')
|
||||
export const WEB_PORT = parseInt(PARSED_ADDR[2]!)
|
||||
export const WEB_ADDR = PARSED_ADDR[1]!
|
||||
|
||||
// Constants
|
||||
export const PROCESSOR_PREFIX = 'file://'
|
||||
export const PROCESSOR_DIRECT = 'https://suzzygames.com' // todo: reimplement direct urls for og tags and such
|
||||
export const PROCESSOR_INDEX: Record<string, number> = {}
|
||||
export const PROCESSOR_ASSETS = new Array<{
|
||||
type: string;
|
||||
size: number;
|
||||
file: string | Buffer;
|
||||
}>()
|
||||
|
||||
// Presets Available to Image Processor
|
||||
export const PROCESSOR_PRESETS: Record<string, {
|
||||
height: number;
|
||||
width: number;
|
||||
quality: number;
|
||||
format: keyof sharp.FormatEnum;
|
||||
resize: sharp.ResizeOptions;
|
||||
}> = {
|
||||
'icon': {
|
||||
height: 64,
|
||||
width: 64,
|
||||
quality: 100,
|
||||
format: 'png',
|
||||
resize: {}
|
||||
},
|
||||
'branding': {
|
||||
height: 128,
|
||||
width: 128,
|
||||
quality: 80,
|
||||
format: 'png',
|
||||
resize: {
|
||||
fit: 'contain',
|
||||
kernel: 'lanczos3',
|
||||
background: {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
alpha: 0
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Common extensions and their MIME types
|
||||
export const CONTENT_TYPE_MAP: Record<string, string> = {
|
||||
// Text and Web
|
||||
'html': 'text/html',
|
||||
'htm': 'text/html',
|
||||
'js': 'text/javascript',
|
||||
'css': 'text/css',
|
||||
'json': 'application/json',
|
||||
'xml': 'application/xml',
|
||||
'csv': 'text/csv',
|
||||
'txt': 'text/plain',
|
||||
'md': 'text/markdown',
|
||||
// Images
|
||||
'png': 'image/png',
|
||||
'jpeg': 'image/jpeg',
|
||||
'jpg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'bmp': 'image/bmp',
|
||||
'webp': 'image/webp',
|
||||
'svg': 'image/svg+xml',
|
||||
'ico': 'image/x-icon',
|
||||
'tiff': 'image/tiff',
|
||||
'heic': 'image/heic',
|
||||
// Audio
|
||||
'mp3': 'audio/mpeg',
|
||||
'wav': 'audio/wav',
|
||||
'ogg': 'audio/ogg',
|
||||
'm4a': 'audio/mp4',
|
||||
'flac': 'audio/flac',
|
||||
'opus': 'audio/opus',
|
||||
// Video
|
||||
'mp4': 'video/mp4',
|
||||
'webm': 'video/webm',
|
||||
'avi': 'video/x-msvideo',
|
||||
'mov': 'video/quicktime',
|
||||
'mkv': 'video/x-matroska',
|
||||
// Fonts
|
||||
'woff': 'font/woff',
|
||||
'woff2': 'font/woff2',
|
||||
'ttf': 'font/ttf',
|
||||
'otf': 'font/otf',
|
||||
'eot': 'application/vnd.ms-fontobject',
|
||||
// Application files
|
||||
'pdf': 'application/pdf',
|
||||
'zip': 'application/zip',
|
||||
'gz': 'application/gzip',
|
||||
'tar': 'application/x-tar',
|
||||
'rar': 'application/vnd.rar',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
'exe': 'application/vnd.microsoft.portable-executable',
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { PROCESSOR_ASSETS, PROCESSOR_INDEX, PROCESSOR_PREFIX, PRODUCTION, WEB_ADDR, WEB_PORT } from './constants'
|
||||
import { Empty, Redirect, Static } from './functions/serveFunctions'
|
||||
import { processFile } from './functions/processFile'
|
||||
import { Articles } from './class/articles'
|
||||
import Log from './functions/logger'
|
||||
import express from 'express'
|
||||
|
||||
// todo: implement gzip compression
|
||||
|
||||
(async () => {
|
||||
const blog = new Articles('blog', '/blog')
|
||||
const library = new Articles('library', '/library')
|
||||
const app = express()
|
||||
app.disable('x-powered-by')
|
||||
app.disable('etag')
|
||||
|
||||
// Dynamic Routes
|
||||
app.get(['/blog', '/blog/:cat', '/blog/:cat/:opt'], blog.serveContent)
|
||||
app.get(['/library/:cat', '/library/:cat/:opt'], library.serveContent)
|
||||
app.get('/library/snippet', library.serveSnippet)
|
||||
app.get('/library*', Redirect('/library/home/welcome'))
|
||||
|
||||
// Static Routes
|
||||
app.get('/', await Static('views/public/index.pug'))
|
||||
app.get('/projects', await Static('views/public/projects.pug'))
|
||||
app.get('/login', await Static('views/public/login.pug'))
|
||||
app.get('/signup', await Static('views/public/signup.pug'))
|
||||
app.get('/password-reset', await Static('views/public/password-reset.pug'))
|
||||
app.get('/password-update', await Static('views/public/password-update.pug'))
|
||||
app.get('/verify-email', await Static('views/public/verify-email.pug'))
|
||||
app.get('/verify-login', await Static('views/public/verify-login.pug'))
|
||||
app.get('/challenge-picross', await Static('views/public/challenge-picross.pug'))
|
||||
|
||||
// app.get('/profile', await Static('views/profile/index.pug'))
|
||||
// app.get('/profile/devices', await Static('views/profile/devices.pug'))
|
||||
// app.get('/profile/security', await Static('views/profile/security.pug'))
|
||||
// app.get('/profile/applications', await Static('views/profile/applications.pug'))
|
||||
// app.get('/profile/connections', await Static('views/profile/connections.pug'))
|
||||
// app.get('/profile*', Redirect('/profile'))
|
||||
app.get('/assets*', async (req, res) => {
|
||||
const index = PROCESSOR_INDEX[req.originalUrl]
|
||||
if (index === undefined) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
const asset = PROCESSOR_ASSETS[index]
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
res.setHeader('Content-Type', asset.type)
|
||||
res.setHeader('Content-Length', asset.size)
|
||||
res.end(asset.file)
|
||||
})
|
||||
|
||||
if (!PRODUCTION) {
|
||||
app.get('/asset-processor*', async (req, res) => {
|
||||
processFile('file://' + req.originalUrl.slice('/asset-processor/'.length))
|
||||
.then(uri => {
|
||||
const asset = PROCESSOR_ASSETS[PROCESSOR_INDEX[uri]]
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
res.setHeader('Content-Type', asset.type)
|
||||
res.setHeader('Content-Length', asset.size)
|
||||
res.end(asset.file)
|
||||
})
|
||||
.catch(e => {
|
||||
res.status(500)
|
||||
res.end(`Caught Processor Error!\n${String(e)}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
app.get('*', await Static('views/404.pug'))
|
||||
app.use(Empty(404))
|
||||
|
||||
app.listen(WEB_PORT, WEB_ADDR, () => {
|
||||
Log('INFO', 'http', `Server Listening @ ${WEB_ADDR}:${WEB_PORT}`)
|
||||
})
|
||||
|
||||
// Array of items not referenced but required!
|
||||
const INCLUDES = new Array<string>(
|
||||
// scripts/shared-utils.ts
|
||||
'file://images/default-icon.png',
|
||||
'file://images/default-user0.png',
|
||||
'file://images/default-user1.png',
|
||||
'file://images/default-user2.png',
|
||||
'file://images/default-user3.png',
|
||||
'file://images/default-user4.png',
|
||||
'file://images/default-user5.png',
|
||||
|
||||
// scripts/shared-utils.ts
|
||||
'file://icons/badge-crown.svg',
|
||||
'file://icons/badge-star.svg',
|
||||
'file://icons/badge-picross.svg',
|
||||
|
||||
// scripts/template-article.ts
|
||||
'file://icons/icon-play.svg',
|
||||
'file://icons/icon-pause.svg',
|
||||
|
||||
// todo: Email Directory
|
||||
)
|
||||
for (const uri of INCLUDES) {
|
||||
processFile(uri)
|
||||
}
|
||||
|
||||
})()
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
type FilePath = string;
|
||||
|
||||
export interface Locals {
|
||||
relevantArticle?: ManifestArticle // Current Article (if any)
|
||||
relevantArticles: ManifestArticle[] // List of all articles filtered by current category
|
||||
relevantCategory?: ManifestEntry // Relevant Articles
|
||||
articles: ManifestArticle[] // List of all articles in manifest
|
||||
authors: ManifestEntry[] // List of all authors in manifest
|
||||
categories: ManifestEntry[] // List of all categories in manifest
|
||||
pageIndex: number // Current Index given by user
|
||||
pageOffset: number // Current Offset for relevantArticles
|
||||
pageTotal: number // Total amount of relevant pages
|
||||
pageCurrent: number // Current Page Number
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
noBrowser?: boolean;
|
||||
itemsPerPage?: number;
|
||||
categories: Array<ManifestEntry>
|
||||
authors: Array<ManifestEntry>
|
||||
articles: Array<FilePath>
|
||||
}
|
||||
|
||||
export interface ManifestEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: FilePath;
|
||||
}
|
||||
|
||||
export interface ManifestArticle {
|
||||
info: {
|
||||
id: string;
|
||||
created: string;
|
||||
categoryId: string;
|
||||
authorId: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
banner: FilePath;
|
||||
noindex: string;
|
||||
}
|
||||
path: string;
|
||||
category: ManifestEntry;
|
||||
author: ManifestEntry;
|
||||
elements: Array<Element>;
|
||||
}
|
||||
|
||||
export type Element =
|
||||
HeaderElement |
|
||||
SubheaderElement |
|
||||
QuoteElement |
|
||||
ImageElement |
|
||||
TextElement |
|
||||
BannerElement |
|
||||
BeanieElement |
|
||||
VideoElement |
|
||||
AudioElement |
|
||||
ListElement |
|
||||
TableElement |
|
||||
CodeElement
|
||||
|
||||
// Header/Chapter Element
|
||||
export interface HeaderElement {
|
||||
type: 'header';
|
||||
value: string;
|
||||
}
|
||||
|
||||
// Subchapter Element
|
||||
export interface SubheaderElement {
|
||||
type: 'subheader';
|
||||
value: string;
|
||||
}
|
||||
|
||||
|
||||
// Quote Text Element
|
||||
export interface QuoteElement {
|
||||
type: 'quote';
|
||||
value: string;
|
||||
}
|
||||
|
||||
// Text Element
|
||||
export interface TextElement {
|
||||
type: 'text';
|
||||
items: {
|
||||
tag: string;
|
||||
content: string;
|
||||
target?: string;
|
||||
href?: string;
|
||||
}[]
|
||||
}
|
||||
|
||||
// Bordered Image Element
|
||||
export interface ImageElement {
|
||||
type: 'image';
|
||||
resource: string;
|
||||
caption: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
// Borderless Image Element
|
||||
export interface BannerElement {
|
||||
type: 'banner';
|
||||
resource: string;
|
||||
caption: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
// Borderless Image Element with Download on Click
|
||||
export interface BeanieElement {
|
||||
type: 'beanie';
|
||||
resource: string;
|
||||
caption: string; // Unused
|
||||
alt: string;
|
||||
}
|
||||
|
||||
// Video Element
|
||||
export interface VideoElement {
|
||||
type: 'video';
|
||||
resource: string;
|
||||
caption: string;
|
||||
alt: string;
|
||||
cover: string;
|
||||
}
|
||||
|
||||
// Audio Player
|
||||
export interface AudioElement {
|
||||
type: 'audio';
|
||||
resource: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// List Element
|
||||
export interface ListElement {
|
||||
type: 'list';
|
||||
items: string[];
|
||||
}
|
||||
|
||||
// Table Element
|
||||
export interface TableElement {
|
||||
type: 'table';
|
||||
items: string[][];
|
||||
}
|
||||
|
||||
// Code Element
|
||||
export interface CodeElement {
|
||||
type: 'code';
|
||||
content: string;
|
||||
}
|
||||
Reference in New Issue
Block a user