Skip to content

Commit

Permalink
refactor: move code to classes, add DI and unit tests (stage 2) (#3)
Browse files Browse the repository at this point in the history
* refactor: add unit tests and DI (stage 2)

* fix: lint

* fix: adapt changes after linter

* fix: cleanup

* feat: add test helper to register injected dependencies

* chore: remove unused package

* feat: change DI library, add FileSystemHelper tests

* refactor: no spy objects creation

* refactor: move compile functionality to Compiler class

* test: add unit tests for FileSystemHelper

* refactor: move classes to lib sub-directory

* refactor: remove spyOn substitution on Messages class in FileSystemHelper tests

* test: add unit test for the Compiler class

* fix: cleanup

* refactor: move Compiler class to DI, read Config via Provider abstraction

* feat: add config cache to Compiler class

* feat: change ci/cd to check all aspects

* feat: print skip logs, fix ro mode

* feat: add Styler class, revert read-only build mode

* docs: force update image

* chore: move to mocha test engine

* feat: print overall error message, cleanup

* 1.1.0
  • Loading branch information
sapachev authored Jan 31, 2023
1 parent 33fa50d commit f680270
Show file tree
Hide file tree
Showing 28 changed files with 4,605 additions and 332 deletions.
25 changes: 12 additions & 13 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,26 @@ name: Node.js CI

on:
push:
branches: [ "main" ]
branches: ["main"]
pull_request:
branches: [ "main" ]
branches: ["main"]

jobs:
build:

runs-on: ubuntu-latest

strategy:
matrix:
node-version: [18.x]

steps:
- uses: actions/checkout@v3
- name: Prepare Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Run Linter Check
run: npm run lint
- uses: actions/checkout@v3
- name: Prepare Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- name: Install Dependencies
run: npm ci
- name: Check
run: npm run check
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
node_modules/
dist/
dist/
.nyc_output/
coverage/
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Lightweight tool to create static HTTP Error Pages in minimalistic adaptive and
* Accessibility (a11y)
* Built-in web server config generator (Nginx)

![Screenshot](https://sapachev.com/share/error-pages/screenshot.png?2)
![Screenshot](https://sapachev.com/share/error-pages/screenshot.png?3)

# Demo

Expand Down Expand Up @@ -44,8 +44,8 @@ Nginx config located in `/dist/<locale>/nginx-error-pages.conf` file and can be

# How to improve default pages

* *Extensibility* A new pages can be added by adding new json files in `scr/<locale>` directory. The page name must follow to format `<HTTP code>.json` (`<HTTP code>` is a number, related to specific HTTP status code). You can put any additional data to json files, that you want to display on a page. In case of common variables, you can use `common.json` file to define them.
* *Customization* By editing default theme you can add anything you want. In case if you want to have own page design, you can create a new theme and apply it by editing `config.json` file. All assets (images, fonts, etc) must be placed to `@assets` directory (note: the `@assets` name is used to avoid a naming collision with default assets directory name in common cases). By default the [mustache.js](https://www.npmjs.com/package/mustache) is used as a template engine and [Tailwind](https://tailwindcss.com/) as a CSS framework. Entry point of Tailwind styles must be located in `themes/<name>/@assets/css/main.tcss` file. Custom Tailwind theme settings can be added to `theme.tailwind.config.js` file located in a root of theme directory. Also Tailwind can be disabled by editing `tailwind` option in `config.js`.
* *Extensibility* A new pages can be added by adding new json files in `scr/<locale>` directory. The page name must follow to format `<HTTP code>.json` (`<HTTP code>` is Number, related to specific HTTP status code). You can put any additional data to json files, that you want to display on a page. In case of common variables, you can use `common.json` file to define them.
* *Customization* By editing default theme you can add anything you want. In case if you want to have own page design, you can create a new theme and apply it by editing `config.json` file. All assets (images, fonts, etc) must be placed to `@assets` directory (note: the `@assets` name is used to avoid a naming collision with default assets directory name in common cases). By default the [mustache.js](https://www.npmjs.com/package/mustache) is used as a template engine and [Tailwind](https://tailwindcss.com/) as a CSS framework. Entry point of Tailwind styles must be located in `themes/<name>/@assets/css/main.twnd.css` file. Custom Tailwind theme settings can be added to `theme.tailwind.config.js` file located in a root of theme directory. If you don't want to use Tailwind builder, then it can be disabled by editing `tailwind` option in `config.js`.
* *Localization* If you need to change default text messages, then you can simply edit existing files in`src/<locale>` directory according to your needs. If you want to create your own localization, just simply add new locale directory and create set of source files. After new locale adding, change `locale` property in `config.json` file, located in a root.


Expand Down
53 changes: 35 additions & 18 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
import "reflect-metadata";

import { Container } from "inversify";

import { Compiler, ICompiler } from "./lib/classes/Compiler";
import { ChildProcessWrapper, IChildProcessWrapper } from "./lib/classes/ChildProcessWrapper";
import { FileSystemHelper, IFileSystemHelper } from "./lib/classes/FileSystemHelper";
import { IFileSystemWrapper, NodeFS } from "./lib/classes/FileSystemWrapper";
import { ILogger, Logger } from "./lib/classes/Logger";
import { Main } from "./lib/classes/Main";
import { IStyler, Styler } from "./lib/classes/Styler";

import { DI_TOKENS } from "./lib/tokens";
import { Config, ConfigProvider } from "./lib/interfaces";
import { DEFAULTS } from "./lib/constants";
import { copyAssets, flush, readConfig } from "./lib/fs";
import { compile } from "./lib/compile";
import { buildTailwind } from "./lib/style";
import { Messages } from "./lib/classes/Messages";
import { MessagesEnum } from "./messages";

readConfig(DEFAULTS.CONFIG)
.then(async (config) => {
await flush(DEFAULTS.DIST);
await compile(config);
await buildTailwind(config);
await copyAssets(`${DEFAULTS.THEMES}/${config.theme}/${DEFAULTS.ASSETS}`, `${DEFAULTS.DIST}/${DEFAULTS.ASSETS}`);
})
.catch((err) => {
console.error(`
An error happened during compile process. Please, check 'README.md' to get more details about calling this process.
// Register DI
const runContainer = new Container({ defaultScope: "Singleton" });
runContainer.bind<ICompiler>(DI_TOKENS.COMPILER).to(Compiler);
runContainer.bind<IChildProcessWrapper>(DI_TOKENS.CHILD_PROCESS).to(ChildProcessWrapper);
runContainer.bind<IFileSystemHelper>(DI_TOKENS.FS_HELPER).to(FileSystemHelper);
runContainer.bind<IFileSystemWrapper>(DI_TOKENS.FS).to(NodeFS);
runContainer.bind<ILogger>(DI_TOKENS.LOGGER).to(Logger);
runContainer.bind<IStyler>(DI_TOKENS.STYLER).to(Styler);

Error Message:
${err.message}
runContainer.bind<ConfigProvider>(DI_TOKENS.CONFIG_PROVIDER).toProvider<Config>((ctx) => {
return () => {
const fsHelper = ctx.container.get<IFileSystemHelper>(DI_TOKENS.FS_HELPER);
return fsHelper.readConfig(DEFAULTS.CONFIG);
};
});

Error Stack:
${err.stack}
`);
runContainer
.resolve(Main)
.start()
.catch((err) => {
runContainer.get<ILogger>(DI_TOKENS.LOGGER).print(Messages.error(MessagesEnum.OVERALL, err));
});
2 changes: 1 addition & 1 deletion lib/_constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ exports.DefaultPaths = {
PACKAGE: "./package.json",
SNIPPETS: "./snippets",
SRC: "./src",
TAILWIND_ENTRY: "main.tcss",
TAILWIND_ENTRY: "main.twnd.css",
THEMES: "./themes",
};
21 changes: 21 additions & 0 deletions lib/classes/ChildProcessWrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { exec } from "child_process";
import { injectable } from "inversify";
import { promisify } from "util";

export interface IChildProcessWrapper {
exec(cmd: string): Promise<{ stdout: string; stderr: string }>;
}

@injectable()
export class ChildProcessWrapper implements IChildProcessWrapper {
exec(cmd: string) {
return promisify(exec)(cmd);
}
}

@injectable()
export class MockChildProcessWrapper implements IChildProcessWrapper {
async exec() {
return null;
}
}
112 changes: 112 additions & 0 deletions lib/classes/Compiler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { injectable, inject } from "inversify";
import { PackageId } from "typescript";

import { IFileSystemHelper } from "./FileSystemHelper";
import { ILogger } from "./Logger";
import { Messages } from "./Messages";
import { Renderer } from "./Renderer";

import { DEFAULTS } from "../constants";
import { Config, ConfigProvider, TemplateVariables } from "../interfaces";
import { DI_TOKENS } from "../tokens";
import { MessagesEnum } from "../../messages";

export const SRC_CODE_PATTERN = /^[0-9]{3}(?=\.json$)/i;

export interface ICompiler {
initTemplateVariables(): Promise<TemplateVariables>;
getStatusList(): Promise<Set<number>>;
makePages(): Promise<void>;
makeConfigs(): Promise<void>;
}

@injectable()
export class Compiler implements ICompiler {
private config: Config;
private statusList: Set<number> = new Set();

constructor(
@inject(DI_TOKENS.CONFIG_PROVIDER) private configProvider: ConfigProvider,
@inject(DI_TOKENS.FS_HELPER) private fsHelper: IFileSystemHelper,
@inject(DI_TOKENS.LOGGER) private logger: ILogger
) {}

async initTemplateVariables(): Promise<TemplateVariables> {
const config = await this.getConfig();

const pkg = await this.fsHelper.readJson<PackageId>(DEFAULTS.PACKAGE);
return {
locale: config.locale,
version: pkg.version,
};
}

async getConfig(): Promise<Config> {
if (!this.config) {
this.config = await this.configProvider();
}
return this.config;
}

async getStatusList(): Promise<Set<number>> {
const config = await this.getConfig();

if (this.statusList.size === 0) {
await this.fsHelper.readDir(`${DEFAULTS.SRC}/${config.locale}/`).then((files) => {
files.forEach((file) => {
const match = file.match(SRC_CODE_PATTERN);
if (match) {
this.statusList.add(Number(match[0]));
}
});
});
}
return this.statusList;
}

async makePages(): Promise<void> {
const config = await this.getConfig();

this.logger.print(Messages.info(MessagesEnum.COMPILE_PAGES));
const list = await this.getStatusList();
if (list.size > 0) {
const initVars = await this.initTemplateVariables();
const commonVars = await this.fsHelper.readJson<TemplateVariables>(`${DEFAULTS.SRC}/${config.locale}/common.json`);
const template = await this.fsHelper.readFile(`${DEFAULTS.THEMES}/${config.theme}/template.html`);

await Promise.all(
Array.from(list).map(async (code) => {
const statusVars = await this.fsHelper.readJson<TemplateVariables>(`${DEFAULTS.SRC}/${config.locale}/${code}.json`);
const path = `${DEFAULTS.DIST}/${code}.html`;

this.logger.print(Messages.list(path));

await this.fsHelper.writeFile(path, Renderer.renderTemplate(template, { ...initVars, ...commonVars, ...statusVars, code }));
})
);
} else {
throw new Error(Messages.text(MessagesEnum.NO_SOURCE_DATA));
}
}

async makeConfigs(): Promise<void> {
this.logger.print(Messages.info(MessagesEnum.COMPILE_CONFIGS));
const list = await this.getStatusList();
if (list.size > 0) {
const snippets = await this.fsHelper.readDir(`${DEFAULTS.SNIPPETS}/`);

await Promise.all(
snippets.map(async (snippet) => {
const path = `${DEFAULTS.DIST}/${snippet}`;

this.logger.print(Messages.list(path));

const template = await this.fsHelper.readFile(`${DEFAULTS.SNIPPETS}/${snippet}`);
await this.fsHelper.writeFile(path, Renderer.renderTemplate(template, { codes: Array.from(list) }));
})
);
} else {
throw new Error(Messages.text(MessagesEnum.NO_SOURCE_DATA));
}
}
}
128 changes: 128 additions & 0 deletions lib/classes/FileSystemHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { injectable, inject } from "inversify";

import { Config } from "../interfaces";
import { Messages } from "./Messages";

import { MessagesEnum } from "../../messages";
import { MANDATORY_CONFIG_PROPS } from "../constants";
import { ILogger } from "./Logger";
import { DI_TOKENS } from "../tokens";
import { IFileSystemWrapper } from "./FileSystemWrapper";
import { Styler } from "./Styler";

export interface IFileSystemHelper {
copyAssets(src: string, dest: string): Promise<void>;
ensure(path: string): Promise<boolean>;
flush(path: string): Promise<void>;
readDir(path: string): Promise<string[]>;
readFile(path: string): Promise<string>;
readJson<T>(path: string): Promise<T>;
readConfig(path: string): Promise<Config>;
writeFile(path: string, data: string): Promise<void>;
}

@injectable()
export class FileSystemHelper implements IFileSystemHelper {
constructor(@inject(DI_TOKENS.FS) private fs: IFileSystemWrapper, @inject(DI_TOKENS.LOGGER) private logger: ILogger) {}

async copyAssets(src: string, dest: string): Promise<void> {
if (await this.ensure(src)) {
this.logger.print(Messages.info(MessagesEnum.COPYING_ASSETS));
await this.fs.cp(src, dest, {
recursive: true,
// TODO: add Logger.print() to filter function
filter: Styler.sourceStyleFilter,
});
// TODO: IMPROVEMENT: return list of copied files to add into web server configs as fixed locations
} else {
this.logger.print(Messages.warn(MessagesEnum.NO_ASSETS_TO_COPY));
}
}

async ensure(path: string): Promise<boolean> {
try {
await this.fs.access(path);
return true;
} catch (_) {
return false;
}
}

async flush(path: string): Promise<void> {
this.logger.print(Messages.info(MessagesEnum.FLUSH_DESTINATION, { path }));

await this.fs.rm(path, { force: true, recursive: true });
await this.fs.mkdir(path, { recursive: true });
}

async readDir(path: string): Promise<string[]> {
if (await this.ensure(path)) {
return await this.fs.readDir(path);
} else {
throw new Error(Messages.text(MessagesEnum.NO_DIRECTORY, { path }));
}
}

async readFile(path: string): Promise<string> {
return await this.fs.readFile(path).then(String);
}

async readJson<T>(path: string): Promise<T> {
if (await this.ensure(path)) {
return await this.readFile(path).then(JSON.parse);
}
return {} as T;
}

async readConfig(path: string): Promise<Config> {
const config = await this.readJson<Config>(path);

// Check mandatory config properties
MANDATORY_CONFIG_PROPS.forEach((prop) => {
if (config[prop] === undefined) {
throw new Error(Messages.text(MessagesEnum.NO_CONFIG_PROPERTY, { prop, path }));
}
});

return config;
}

async writeFile(path: string, data: string, opts = { flag: "w+" }): Promise<void> {
return await this.fs.writeFile(path, data, opts);
}
}

@injectable()
export class MockFileSystemHelper implements IFileSystemHelper {
async copyAssets() {
return null;
}

async ensure() {
return true;
}

async flush() {
return null;
}

async readDir() {
return [""];
}

async readFile() {
return "";
}

async readJson<T>() {
return {} as T;
}

async readConfig() {
return {} as Config;
}

async writeFile() {
return null;
}
}
Loading

0 comments on commit f680270

Please sign in to comment.