aboutsummaryrefslogtreecommitdiffstats
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/builder/build.ts85
-rw-r--r--app/builder/pages.ts112
-rw-r--r--app/builder/render.ts13
-rw-r--r--app/index.ts34
-rw-r--r--app/logger.ts23
-rw-r--r--app/webserver/watcher.ts125
-rw-r--r--app/webserver/webserver.ts39
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);
+
+};