diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/builder/build.ts | 85 | ||||
| -rw-r--r-- | app/builder/pages.ts | 112 | ||||
| -rw-r--r-- | app/builder/render.ts | 13 | ||||
| -rw-r--r-- | app/index.ts | 34 | ||||
| -rw-r--r-- | app/logger.ts | 23 | ||||
| -rw-r--r-- | app/webserver/watcher.ts | 125 | ||||
| -rw-r--r-- | app/webserver/webserver.ts | 39 |
7 files changed, 431 insertions, 0 deletions
diff --git a/app/builder/build.ts b/app/builder/build.ts new file mode 100644 index 0000000..d0ed5a9 --- /dev/null +++ b/app/builder/build.ts @@ -0,0 +1,85 @@ +import { render } from './render.js'; +import { Page, PageDirectory } from './pages.js'; +import fs from 'fs'; +import path from 'path'; +import { logger } from '../logger.js'; + +export async function buildPages(): Promise<{ success: boolean, errors: number, pageDirectory: PageDirectory}> { + // Recreate output directory + try { + if (fs.existsSync(process.env.OUTPUT_DIR)) { + fs.rmSync(process.env.OUTPUT_DIR, { recursive: true }); + } + fs.mkdirSync(process.env.OUTPUT_DIR); + } catch (e) { + logger.error(`Failed to create output directory: ${e.message}`); + return { success: false, errors: 0, pageDirectory: null }; + } + + + // Load pages + logger.info(`Reading pages from disk...`); + const pageDirectory = new PageDirectory(process.env.PAGES_DIR); + + let pagesCount = Object.keys(pageDirectory.getPages()).length; + logger.info(`Found ${pagesCount} pages.`); + + + // Render pages + logger.info(`Rendering pages...`); + let pagesRendered = 0; + let pagesFailed = 0; + for (const page of pageDirectory.getPages()) { + if (await renderPage(page, pageDirectory)) { + pagesRendered++; + } else { + pagesFailed++; + } + } + + logger.info(`Rendered ${pagesRendered} of ${pagesCount} pages.`); + + + // Copy static files + logger.info(`Copying static files...`); + try { + fs.cpSync(`${process.env.STATIC_DIR}`, `${process.env.OUTPUT_DIR}/static`, { recursive: true }); + logger.info(`Done.`); + } catch (e) { + logger.error(`Failed to copy static files: ${e.message}`); + } + + return { success: true, errors: pagesFailed, pageDirectory: pageDirectory}; +} + +async function renderPage(page: Page, pageDirectory: PageDirectory): Promise<boolean> { + let html; + try { + html = await render(page, pageDirectory); + } catch (e) { + logger.error(`Failed to render page ${page.originalPath}: ${e.message}`); + return false; + } + + try { + const file = page.buildPath; + const dir = path.dirname(file); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(file, html); + } catch (e) { + logger.error(`Failed to write page ${page.buildPath}: ${e.message}`); + return false; + } + return true; +} + +export async function rebuildSinglePage(path: string, pageDirectory: PageDirectory): Promise<boolean> { + const page = pageDirectory.loadPage(path); + if (!page) { + return false; + } + + return await renderPage(page, pageDirectory); +} diff --git a/app/builder/pages.ts b/app/builder/pages.ts new file mode 100644 index 0000000..c1c2474 --- /dev/null +++ b/app/builder/pages.ts @@ -0,0 +1,112 @@ +import { readFileSync } from 'fs'; +import glob from 'glob'; +import { logger } from '../logger.js' +import { marked } from 'marked'; +import matter from 'gray-matter'; + +export function parsePage(page: Page) { + try { + const result = matter(page.raw); + const config = result.data; + const html = marked.parse(result.content, { mangle: false, headerIds: false }); + + page.html = html; + page.config = config; + page.view = config.view || 'index'; + page.buildTime = Date.now(); + } catch (e) { + logger.error(`Failed to parse page ${page.originalPath}: ${e.message}`); + } +} + +function loadRaw(path: string): string { + return readFileSync(`${path}`, 'utf-8'); +} + +export class PageDirectory { + private pagesPath: string; + + private pages: Record<string, Page> = {}; + private lastFullBuild: number; + + constructor(pagesPath: string) { + this.pagesPath = pagesPath; + + for (const page in this.pages) { + delete this.pages[page]; + } + + const localPages = glob.sync(`**/*.{md,html}`, { cwd: this.pagesPath }) + + localPages.forEach(this.loadPage); + + this.lastFullBuild = Date.now(); + } + + public loadPage = (page: string): Page => { + let route = page.replace(/\.[^.]*$/,'') + let name = /[^/]*$/.exec(route)[0]; + let originalPath = page; + let fullPath = `${this.pagesPath}/${page}` + let buildPath = `${process.env.OUTPUT_DIR}/${route}.html` + let view = `${route}.ejs` + let raw: string; + try { + raw = loadRaw(fullPath); + } catch (e) { + logger.error(`Failed to read page ${originalPath}: ${e.message}`); + return undefined; + } + + this.pages[route] = { + route: route, + name: name, + originalPath: originalPath, + fullPath: fullPath, + buildPath: buildPath, + view: view, + raw: raw, + buildTime: 0, + config: {} + } + + parsePage(this.pages[route]); + + return this.pages[route]; + } + + public removePage = (page: string): void => { + let route = page.replace(/\.[^.]*$/,'') + delete this.pages[route]; + } + + public get(name: string): Page { + const page = this.pages[name]; + if (!page) { + return undefined; + } + + return page; + } + + public getPages(): Page[] { + return Object.values(this.pages); + } + + public getPagesBeginningWith(prefix: string): Page[] { + return Object.values(this.pages).filter(page => page.route.startsWith(prefix)); + } +} + +export type Page = { + html?: string; + raw?: string; + route: string; + name: string; + originalPath: string; + fullPath: string; + buildPath: string; + view: string; + buildTime: number; + config: any; +}; diff --git a/app/builder/render.ts b/app/builder/render.ts new file mode 100644 index 0000000..28c6d80 --- /dev/null +++ b/app/builder/render.ts @@ -0,0 +1,13 @@ +import { Page, PageDirectory } from "./pages"; +import ejs from 'ejs'; +import path from 'path'; + +export async function render(page: Page, pageDirectory: PageDirectory): Promise<string> { + const options = { + page: page, + site: { + pages: pageDirectory, + } + }; + return await ejs.renderFile(path.join(process.env.VIEWS_DIR, `${page.view}.ejs`), options); +} diff --git a/app/index.ts b/app/index.ts new file mode 100644 index 0000000..1cb278a --- /dev/null +++ b/app/index.ts @@ -0,0 +1,34 @@ +import dotenv from 'dotenv-defaults'; +import { logger } from './logger.js'; +import { buildPages } from './builder/build.js'; + +dotenv.config(); + +const startDate = new Date(); + +logger.info(''); +logger.info('panulat, a static site generator'); +logger.info(startDate.toString()); +logger.info(''); +logger.info(`Static directory: ${process.env.STATIC_DIR}`); +logger.info(` Pages directory: ${process.env.PAGES_DIR}`); +logger.info(` Views directory: ${process.env.VIEWS_DIR}`); +logger.info(`Output directory: ${process.env.OUTPUT_DIR}`); +logger.info(` Webserver: ${process.env.WEBSERVER_ENABLED === 'true' ? 'enabled' : 'disabled'}`); +logger.info(` Autorebuild: ${process.env.WEBSERVER_AUTOREBUILD === 'true' ? 'enabled' : 'disabled'}`); +logger.info(''); + +const {success, errors, pageDirectory} = await buildPages(); + +logger.info(''); +if (!success) { + logger.error(`Build failed. Quitting.`); + process.exit(1); +} + +logger.info(`Finished${errors > 0 ? `, with ${errors} errors` : ''}. Build took ${new Date().getTime() - startDate.getTime()}ms.`); + +if (process.env.WEBSERVER_ENABLED === 'true') { + logger.info(''); + import('./webserver/webserver.js').then(m => m.start(pageDirectory)); +} diff --git a/app/logger.ts b/app/logger.ts new file mode 100644 index 0000000..4ce0150 --- /dev/null +++ b/app/logger.ts @@ -0,0 +1,23 @@ +import winston from 'winston'; + +const enumerateErrorFormat = winston.format((info) => { + if (info instanceof Error) { + Object.assign(info, { message: info.stack }); + } + return info; + }); + +export const logger = winston.createLogger({ + level: process.env.LOGGING_LEVEL === 'development' ? 'debug' : 'info', + format: winston.format.combine( + enumerateErrorFormat(), + winston.format.colorize(), + winston.format.splat(), + winston.format.printf(({ level, message }) => `${level}: ${message}`) + ), + transports: [ + new winston.transports.Console({ + stderrLevels: ['error'], + }), + ], +}); diff --git a/app/webserver/watcher.ts b/app/webserver/watcher.ts new file mode 100644 index 0000000..a792473 --- /dev/null +++ b/app/webserver/watcher.ts @@ -0,0 +1,125 @@ +import chokidar, { FSWatcher } from 'chokidar'; +import { logger } from '../logger.js'; +import { PageDirectory } from '../builder/pages.js'; +import { rebuildSinglePage } from '../builder/build.js'; +import path from 'path'; +import fs from 'fs'; + +function attachPageEvents(watcher: FSWatcher, pages: PageDirectory) { + const onPageChange = async (file: string) => { + logger.info(`File ${file} has been modified, rebuilding...`); + if (await rebuildSinglePage(file, pages)) { + logger.info(`...done`); + } + logger.info(``); + } + + const onPageRemoval = (file: string) => { + logger.info(`File ${file} has been removed, deleting...`); + const page = pages.get(file.replace(/\.[^.]*$/,'')); + if (!page) { + logger.error(`Failed to find page for ${file}`); + return; + } + const joinedPath = path.join(process.env.OUTPUT_DIR, `${page.route}.html`); + try { + fs.rmSync(joinedPath) + } catch (e) { + logger.error(`Failed to remove ${joinedPath}: ${e.message}`); + } + logger.info(`...done`); + logger.info(``); + } + + watcher.on('add', onPageChange); + watcher.on('change', onPageChange); + watcher.on('unlink', onPageRemoval); +} + +function attachStaticEvents(watcher: FSWatcher) { + const onStaticChange = async (file: string) => { + logger.info(`Static file ${file} has been modified, copying...`); + const joinedPath = path.join(process.env.STATIC_DIR, file); + const joinedOutputPath = path.join(process.env.OUTPUT_DIR, 'static', file); + try { + fs.copyFileSync(joinedPath, joinedOutputPath); + logger.info(`...done`); + } catch (e) { + logger.error(`Failed to copy ${joinedPath} to ${joinedOutputPath}: ${e.message}`); + } + logger.info(``); + } + + const onStaticRemoval = (file: string) => { + logger.info(`Static file ${file} has been removed, deleting...`); + const joinedOutputPath = path.join(process.env.OUTPUT_DIR, 'static', file); + try { + fs.rmSync(joinedOutputPath) + logger.info(`...done`); + } catch (e) { + logger.error(`Failed to remove ${joinedOutputPath}: ${e.message}`); + } + logger.info(``); + } + + watcher.on('add', onStaticChange); + watcher.on('change', onStaticChange); + watcher.on('unlink', onStaticRemoval); +} + +function attachViewEvents(watcher: FSWatcher, pages: PageDirectory) { + const onViewChange = async (file: string) => { + logger.info(`View ${file} has been modified, rebuilding pages with view...`); + let pagesWithView = pages.getPages().filter(page => `${page.view}.ejs` === file); + logger.info(`Found ${pagesWithView.length} pages with view ${file}`); + for (const page of pagesWithView) { + logger.info(`Rebuilding page ${page.route}...`); + if (await rebuildSinglePage(page.originalPath, pages)) { + logger.info(`...done`); + } + } + logger.info(``); + } + + const onViewRemoval = (file: string) => { + logger.info(``); + logger.info(`View ${file} has been removed`); + logger.info(``); + } + + watcher.on('add', onViewChange); + watcher.on('change', onViewChange); + watcher.on('unlink', onViewRemoval); +} + +export const start = (pages: PageDirectory) => { + const pagesWatcher = chokidar.watch('.', { + persistent: true, + cwd: process.env.PAGES_DIR, + ignoreInitial: true, + }); + const staticWatcher = chokidar.watch('.', { + persistent: true, + cwd: process.env.STATIC_DIR, + ignoreInitial: true, + }); + const viewsWatcher = chokidar.watch('.', { + persistent: true, + cwd: process.env.VIEWS_DIR, + ignoreInitial: true, + }); + + attachPageEvents(pagesWatcher, pages); + attachStaticEvents(staticWatcher); + attachViewEvents(viewsWatcher, pages); + + const exitHandler = () => { + logger.info(`Stopping file watcher...`); + viewsWatcher.close(); + staticWatcher.close(); + pagesWatcher.close(); + } + + process.on('SIGINT', exitHandler); + process.on('SIGTERM', exitHandler); +} diff --git a/app/webserver/webserver.ts b/app/webserver/webserver.ts new file mode 100644 index 0000000..82caa06 --- /dev/null +++ b/app/webserver/webserver.ts @@ -0,0 +1,39 @@ +import express from 'express'; +import { logger } from '../logger.js'; +import { AddressInfo } from 'net'; +import { PageDirectory } from '../builder/pages.js'; + +const app = express(); + +app.use(express.static(process.env.OUTPUT_DIR, { extensions: ['html'] })); + +export const start = (pages: PageDirectory) => { + const server = app.listen(process.env.WEBSERVER_PORT, () => { + const address = server.address() as AddressInfo; + logger.info(`Serving files from: ${process.env.OUTPUT_DIR}`); + logger.info(` Address: http://localhost:${address.port}`); + logger.info(` ^C to stop`); + logger.info('') + + if (process.env.WEBSERVER_AUTOREBUILD === 'true') { + import('./watcher.js').then((watcher) => { + watcher.start(pages); + }); + } + }); + + const closeServer = () => { + logger.info(`Stopping server...`); + server.close(); + } + + const exitHandler = () => { + if (server.listening) { + closeServer(); + } + } + + process.on('SIGINT', exitHandler); + process.on('SIGTERM', exitHandler); + +}; |
